From 3a958315c6f6badc277f23b564ba6628dae15642 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Fri, 13 Feb 2026 09:19:32 +0100 Subject: [PATCH] refactor/(abacrule): Replace blocking Await.result with non-blocking Future composition in AbacRuleEngine Replace 16 Await.result calls in AbacRuleEngine.executeRule and executeRulesByPolicy with proper Future-based composition. Data fetches (user attributes, auth contexts, entitlements, bank/account/transaction/customer data) now run in parallel rather than sequentially blocking. Update call sites in APIMethods600 to consume the returned Future directly. Add AbacRuleTests covering both executeAbacRule and executeAbacPolicy endpoints (auth, role, success/failure, OR logic). Co-Authored-By: Claude Opus 4.6 --- .../scala/code/abacrule/AbacRuleEngine.scala | 409 +++++++++--------- .../scala/code/api/v6_0_0/APIMethods600.scala | 16 +- .../scala/code/api/v6_0_0/AbacRuleTests.scala | 255 +++++++++++ 3 files changed, 459 insertions(+), 221 deletions(-) create mode 100644 obp-api/src/test/scala/code/api/v6_0_0/AbacRuleTests.scala diff --git a/obp-api/src/main/scala/code/abacrule/AbacRuleEngine.scala b/obp-api/src/main/scala/code/abacrule/AbacRuleEngine.scala index 93fb815371..29942a47f7 100644 --- a/obp-api/src/main/scala/code/abacrule/AbacRuleEngine.scala +++ b/obp-api/src/main/scala/code/abacrule/AbacRuleEngine.scala @@ -13,8 +13,7 @@ import net.liftweb.util.Helpers.tryo import java.util.concurrent.ConcurrentHashMap import scala.collection.JavaConverters._ import scala.collection.concurrent -import scala.concurrent.Await -import scala.concurrent.duration._ +import scala.concurrent.Future /** * ABAC Rule Engine for compiling and executing Attribute-Based Access Control rules @@ -22,7 +21,7 @@ import scala.concurrent.duration._ object AbacRuleEngine { // Cache for compiled ABAC rule functions - private val compiledRulesCache: concurrent.Map[String, Box[AbacRuleFunction]] = + private val compiledRulesCache: concurrent.Map[String, Box[AbacRuleFunction]] = new ConcurrentHashMap[String, Box[AbacRuleFunction]]().asScala /** @@ -36,7 +35,7 @@ object AbacRuleEngine { /** * Compile an ABAC rule from Scala code - * + * * @param ruleId Unique identifier for the rule * @param ruleCode Scala code that defines the rule function * @return Box containing the compiled function or error @@ -56,12 +55,12 @@ object AbacRuleEngine { */ private def compileRuleInternal(ruleCode: String): Box[AbacRuleFunction] = { val fullCode = buildFullRuleCode(ruleCode) - + DynamicUtil.compileScalaCode[AbacRuleFunction](fullCode) match { case Full(func) => Full(func) - case Failure(msg, exception, _) => + case Failure(msg, exception, _) => Failure(s"Failed to compile ABAC rule: $msg", exception, Empty) - case Empty => + case Empty => Failure("Failed to compile ABAC rule: Unknown error") } } @@ -84,9 +83,18 @@ object AbacRuleEngine { |""".stripMargin } + /** + * Helper to lift a Box value into a Future, converting Failure/Empty to a failed Future. + */ + private def boxToFuture[T](box: Box[T], errorMsg: String = "Not found"): Future[T] = box match { + case Full(v) => Future.successful(v) + case Failure(msg, ex, _) => Future.failed(new RuntimeException(msg, ex.openOr(null))) + case Empty => Future.failed(new RuntimeException(errorMsg)) + } + /** * Execute an ABAC rule by IDs (objects are fetched internally) - * + * * @param ruleId The ID of the rule to execute * @param authenticatedUserId The ID of the authenticated user (the person logged in) * @param onBehalfOfUserId Optional ID of user being acted on behalf of (delegation scenario) @@ -98,7 +106,7 @@ object AbacRuleEngine { * @param transactionId Optional transaction ID * @param transactionRequestId Optional transaction request ID * @param customerId Optional customer ID - * @return Box[Boolean] - Full(true) if allowed, Full(false) if denied, Failure on error + * @return Future[Box[Boolean]] - Full(true) if allowed, Full(false) if denied, Failure on error */ def executeRule( ruleId: String, @@ -112,198 +120,179 @@ object AbacRuleEngine { transactionId: Option[String] = None, transactionRequestId: Option[String] = None, customerId: Option[String] = None - ): Box[Boolean] = { - for { + ): Future[Box[Boolean]] = { + val ccOpt = Some(callContext) + val ns = code.api.util.NewStyle.function + + // Validate rule exists and is active (synchronous) + val ruleBox = for { rule <- MappedAbacRuleProvider.getAbacRuleById(ruleId) _ <- if (rule.isActive) Full(true) else Failure(s"ABAC Rule ${rule.ruleName} is not active") - - // Fetch authenticated user (the actual person logged in) - authenticatedUser <- Users.users.vend.getUserByUserId(authenticatedUserId) - - // Fetch non-personal attributes for authenticated user - authenticatedUserAttributes = Await.result( - code.api.util.NewStyle.function.getNonPersonalUserAttributes(authenticatedUserId, Some(callContext)).map(_._1), - 5.seconds - ) - - // Fetch auth context for authenticated user - authenticatedUserAuthContext = Await.result( - code.api.util.NewStyle.function.getUserAuthContexts(authenticatedUserId, Some(callContext)).map(_._1), - 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(_)) - case None => Full(None) - } - - // Fetch attributes for onBehalfOf user if provided - onBehalfOfUserAttributes = onBehalfOfUserId match { - case Some(obUserId) => - Await.result( - code.api.util.NewStyle.function.getNonPersonalUserAttributes(obUserId, Some(callContext)).map(_._1), - 5.seconds - ) - case None => List.empty[UserAttributeTrait] - } - - // Fetch auth context for onBehalfOf user if provided - onBehalfOfUserAuthContext = onBehalfOfUserId match { - case Some(obUserId) => - Await.result( - code.api.util.NewStyle.function.getUserAuthContexts(obUserId, Some(callContext)).map(_._1), - 5.seconds - ) - 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(_)) - case None => Full(None) - } - - // Fetch attributes for target user if provided - userAttributes = userId match { - case Some(uId) => - Await.result( - code.api.util.NewStyle.function.getNonPersonalUserAttributes(uId, Some(callContext)).map(_._1), - 5.seconds - ) - case None => List.empty[UserAttributeTrait] - } - - // Fetch bank if bankId is provided - bankOpt <- bankId match { - case Some(bId) => - tryo(Await.result( - code.api.util.NewStyle.function.getBank(BankId(bId), Some(callContext)).map(_._1), - 5.seconds - )).map(Some(_)) - case None => Full(None) - } - - // Fetch bank attributes if bank is provided - bankAttributes = bankId match { - case Some(bId) => - Await.result( - code.api.util.NewStyle.function.getBankAttributesByBank(BankId(bId), Some(callContext)).map(_._1), - 5.seconds - ) - case None => List.empty[BankAttributeTrait] - } - - // Fetch account if accountId and bankId are provided - accountOpt <- (bankId, accountId) match { - case (Some(bId), Some(aId)) => - tryo(Await.result( - code.api.util.NewStyle.function.getBankAccount(BankId(bId), AccountId(aId), Some(callContext)).map(_._1), - 5.seconds - )).map(Some(_)) - case _ => Full(None) - } - - // Fetch account attributes if account is provided - accountAttributes = (bankId, accountId) match { - case (Some(bId), Some(aId)) => - Await.result( - code.api.util.NewStyle.function.getAccountAttributesByAccount(BankId(bId), AccountId(aId), Some(callContext)).map(_._1), - 5.seconds - ) - case _ => List.empty[AccountAttribute] - } - - // Fetch transaction if transactionId, accountId, and bankId are provided - transactionOpt <- (bankId, accountId, transactionId) match { - case (Some(bId), Some(aId), Some(tId)) => - tryo(Await.result( - code.api.util.NewStyle.function.getTransaction(BankId(bId), AccountId(aId), TransactionId(tId), Some(callContext)).map(_._1), - 5.seconds - )).map(trans => Some(trans)) - case _ => Full(None) - } - - // Fetch transaction attributes if transaction is provided - transactionAttributes = (bankId, transactionId) match { - case (Some(bId), Some(tId)) => - Await.result( - code.api.util.NewStyle.function.getTransactionAttributes(BankId(bId), TransactionId(tId), Some(callContext)).map(_._1), - 5.seconds - ) - case _ => List.empty[TransactionAttribute] - } - - // Fetch transaction request if transactionRequestId is provided - transactionRequestOpt <- transactionRequestId match { - case Some(trId) => - tryo(Await.result( - code.api.util.NewStyle.function.getTransactionRequestImpl(TransactionRequestId(trId), Some(callContext)).map(_._1), - 5.seconds - )).map(tr => Some(tr)) - case _ => Full(None) - } - - // Fetch transaction request attributes if transaction request is provided - transactionRequestAttributes = (bankId, transactionRequestId) match { - case (Some(bId), Some(trId)) => - Await.result( - code.api.util.NewStyle.function.getTransactionRequestAttributes(BankId(bId), TransactionRequestId(trId), Some(callContext)).map(_._1), - 5.seconds - ) - case _ => List.empty[TransactionRequestAttributeTrait] - } - - // Fetch customer if customerId and bankId are provided - customerOpt <- (bankId, customerId) match { - case (Some(bId), Some(cId)) => - tryo(Await.result( - code.api.util.NewStyle.function.getCustomerByCustomerId(cId, Some(callContext)).map(_._1), - 5.seconds - )).map(cust => Some(cust)) - case _ => Full(None) - } - - // Fetch customer attributes if customer is provided - customerAttributes = (bankId, customerId) match { - case (Some(bId), Some(cId)) => - Await.result( - code.api.util.NewStyle.function.getCustomerAttributes(BankId(bId), CustomerId(cId), Some(callContext)).map(_._1), - 5.seconds - ) - case _ => List.empty[CustomerAttribute] - } - - // Compile and execute the rule - compiledFunc <- compileRule(ruleId, rule.ruleCode) - result <- tryo { - 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 + } yield rule + + ruleBox match { + case Full(rule) => + // Validate authenticated user exists (synchronous) + Users.users.vend.getUserByUserId(authenticatedUserId) match { + case Full(authenticatedUser) => + // Fire off all independent async fetches in parallel + val authenticatedUserAttributesF = ns.getNonPersonalUserAttributes(authenticatedUserId, ccOpt).map(_._1) + val authenticatedUserAuthContextF = ns.getUserAuthContexts(authenticatedUserId, ccOpt).map(_._1) + val authenticatedUserEntitlementsF = ns.getEntitlementsByUserId(authenticatedUserId, ccOpt) + + val onBehalfOfUserOpt: Box[Option[User]] = onBehalfOfUserId match { + case Some(obUserId) => Users.users.vend.getUserByUserId(obUserId).map(Some(_)) + case None => Full(None) + } + + val onBehalfOfUserAttributesF = onBehalfOfUserId match { + case Some(obUserId) => ns.getNonPersonalUserAttributes(obUserId, ccOpt).map(_._1) + case None => Future.successful(List.empty[UserAttributeTrait]) + } + val onBehalfOfUserAuthContextF = onBehalfOfUserId match { + case Some(obUserId) => ns.getUserAuthContexts(obUserId, ccOpt).map(_._1) + case None => Future.successful(List.empty[UserAuthContext]) + } + val onBehalfOfUserEntitlementsF = onBehalfOfUserId match { + case Some(obUserId) => ns.getEntitlementsByUserId(obUserId, ccOpt) + case None => Future.successful(List.empty[Entitlement]) + } + + val userOpt: Box[Option[User]] = userId match { + case Some(uId) => Users.users.vend.getUserByUserId(uId).map(Some(_)) + case None => Full(None) + } + + val userAttributesF = userId match { + case Some(uId) => ns.getNonPersonalUserAttributes(uId, ccOpt).map(_._1) + case None => Future.successful(List.empty[UserAttributeTrait]) + } + + val bankOptF = bankId match { + case Some(bId) => ns.getBank(BankId(bId), ccOpt).map(r => Full(Some(r._1)): Box[Option[Bank]]).recover { + case e => Failure(e.getMessage) + } + case None => Future.successful(Full(None): Box[Option[Bank]]) + } + + val bankAttributesF = bankId match { + case Some(bId) => ns.getBankAttributesByBank(BankId(bId), ccOpt).map(_._1) + case None => Future.successful(List.empty[BankAttributeTrait]) + } + + val accountOptF = (bankId, accountId) match { + case (Some(bId), Some(aId)) => + ns.getBankAccount(BankId(bId), AccountId(aId), ccOpt).map(r => Full(Some(r._1)): Box[Option[BankAccount]]).recover { + case e => Failure(e.getMessage) + } + case _ => Future.successful(Full(None): Box[Option[BankAccount]]) + } + + val accountAttributesF = (bankId, accountId) match { + case (Some(bId), Some(aId)) => ns.getAccountAttributesByAccount(BankId(bId), AccountId(aId), ccOpt).map(_._1) + case _ => Future.successful(List.empty[AccountAttribute]) + } + + val transactionOptF = (bankId, accountId, transactionId) match { + case (Some(bId), Some(aId), Some(tId)) => + ns.getTransaction(BankId(bId), AccountId(aId), TransactionId(tId), ccOpt).map(r => Full(Some(r._1)): Box[Option[Transaction]]).recover { + case e => Failure(e.getMessage) + } + case _ => Future.successful(Full(None): Box[Option[Transaction]]) + } + + val transactionAttributesF = (bankId, transactionId) match { + case (Some(bId), Some(tId)) => ns.getTransactionAttributes(BankId(bId), TransactionId(tId), ccOpt).map(_._1) + case _ => Future.successful(List.empty[TransactionAttribute]) + } + + val transactionRequestOptF = transactionRequestId match { + case Some(trId) => + ns.getTransactionRequestImpl(TransactionRequestId(trId), ccOpt).map(r => Full(Some(r._1)): Box[Option[TransactionRequest]]).recover { + case e => Failure(e.getMessage) + } + case _ => Future.successful(Full(None): Box[Option[TransactionRequest]]) + } + + val transactionRequestAttributesF = (bankId, transactionRequestId) match { + case (Some(bId), Some(trId)) => ns.getTransactionRequestAttributes(BankId(bId), TransactionRequestId(trId), ccOpt).map(_._1) + case _ => Future.successful(List.empty[TransactionRequestAttributeTrait]) + } + + val customerOptF = (bankId, customerId) match { + case (Some(bId), Some(cId)) => + ns.getCustomerByCustomerId(cId, ccOpt).map(r => Full(Some(r._1)): Box[Option[Customer]]).recover { + case e => Failure(e.getMessage) + } + case _ => Future.successful(Full(None): Box[Option[Customer]]) + } + + val customerAttributesF = (bankId, customerId) match { + case (Some(bId), Some(cId)) => ns.getCustomerAttributes(BankId(bId), CustomerId(cId), ccOpt).map(_._1) + case _ => Future.successful(List.empty[CustomerAttribute]) + } + + // Combine all futures and execute the rule + for { + authenticatedUserAttributes <- authenticatedUserAttributesF + authenticatedUserAuthContext <- authenticatedUserAuthContextF + authenticatedUserEntitlements <- authenticatedUserEntitlementsF + obUserAttributes <- onBehalfOfUserAttributesF + obUserAuthContext <- onBehalfOfUserAuthContextF + obUserEntitlements <- onBehalfOfUserEntitlementsF + uAttributes <- userAttributesF + bankOptBox <- bankOptF + bankAttrs <- bankAttributesF + accountOptBox <- accountOptF + accountAttrs <- accountAttributesF + transactionOptBox <- transactionOptF + transactionAttrs <- transactionAttributesF + transactionRequestOptBox <- transactionRequestOptF + transactionRequestAttrs <- transactionRequestAttributesF + customerOptBox <- customerOptF + customerAttrs <- customerAttributesF + } yield { + // Chain the Box results together + for { + obUser <- onBehalfOfUserOpt + uOpt <- userOpt + bOpt <- bankOptBox + aOpt <- accountOptBox + tOpt <- transactionOptBox + trOpt <- transactionRequestOptBox + cOpt <- customerOptBox + compiledFunc <- compileRule(ruleId, rule.ruleCode) + result <- tryo { + compiledFunc( + authenticatedUser, authenticatedUserAttributes, authenticatedUserAuthContext, authenticatedUserEntitlements, + obUser, obUserAttributes, obUserAuthContext, obUserEntitlements, + uOpt, uAttributes, + bOpt, bankAttrs, + aOpt, accountAttrs, + tOpt, transactionAttrs, + trOpt, transactionRequestAttrs, + cOpt, customerAttrs, + ccOpt + ) + } + } yield result + } + + case Failure(msg, ex, _) => Future.successful(Failure(msg, ex, Empty)) + case Empty => Future.successful(Failure(s"User not found: $authenticatedUserId")) + } + + case Failure(msg, ex, chain) => Future.successful(Failure(msg, ex, chain)) + case Empty => Future.successful(Empty) + } } - + /** * 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 @@ -315,7 +304,7 @@ object AbacRuleEngine { * @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 + * @return Future[Box[Boolean]] - Full(true) if at least one rule passes (OR logic), Full(false) if all fail */ def executeRulesByPolicy( policy: String, @@ -329,15 +318,15 @@ object AbacRuleEngine { transactionId: Option[String] = None, transactionRequestId: Option[String] = None, customerId: Option[String] = None - ): Box[Boolean] = { + ): Future[Box[Boolean]] = { val rules = MappedAbacRuleProvider.getActiveAbacRulesByPolicy(policy) - + if (rules.isEmpty) { // No rules for this policy - default to allow - Full(true) + Future.successful(Full(true)) } else { - // Execute all rules and check if at least one passes - val results = rules.map { rule => + // Execute all rules in parallel and check if at least one passes + val resultFutures = rules.map { rule => executeRule( ruleId = rule.abacRuleId, authenticatedUserId = authenticatedUserId, @@ -352,21 +341,23 @@ object AbacRuleEngine { 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) + Future.sequence(resultFutures).map { results => + // 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 - * + * * @param ruleCode The Scala code to validate * @return Box[String] - Full("OK") if valid, Failure with error message if invalid */ @@ -401,4 +392,4 @@ object AbacRuleEngine { "rule_ids" -> compiledRulesCache.keys.toList ) } -} \ 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 be38375e30..230f10055b 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 @@ -6846,8 +6846,7 @@ trait APIMethods600 { // userId: the target user being evaluated (defaults to authenticated user) effectiveAuthenticatedUserId = execJson.authenticated_user_id.getOrElse(user.userId) - result <- Future { - val resultBox = AbacRuleEngine.executeRule( + result <- AbacRuleEngine.executeRule( ruleId = ruleId, authenticatedUserId = effectiveAuthenticatedUserId, onBehalfOfUserId = execJson.on_behalf_of_user_id, @@ -6859,9 +6858,7 @@ trait APIMethods600 { transactionId = execJson.transaction_id, transactionRequestId = execJson.transaction_request_id, customerId = execJson.customer_id - ) - - resultBox match { + ).map { case Full(allowed) => AbacRuleResultJsonV600(result = allowed) case Failure(msg, _, _) => @@ -6869,7 +6866,6 @@ trait APIMethods600 { case Empty => AbacRuleResultJsonV600(result = false) } - } } yield { (result, HttpCode.`200`(callContext)) } @@ -6952,8 +6948,7 @@ trait APIMethods600 { // userId: the target user being evaluated (defaults to authenticated user) effectiveAuthenticatedUserId = execJson.authenticated_user_id.getOrElse(user.userId) - result <- Future { - val resultBox = AbacRuleEngine.executeRulesByPolicy( + result <- AbacRuleEngine.executeRulesByPolicy( policy = policy, authenticatedUserId = effectiveAuthenticatedUserId, onBehalfOfUserId = execJson.on_behalf_of_user_id, @@ -6965,9 +6960,7 @@ trait APIMethods600 { transactionId = execJson.transaction_id, transactionRequestId = execJson.transaction_request_id, customerId = execJson.customer_id - ) - - resultBox match { + ).map { case Full(allowed) => AbacRuleResultJsonV600(result = allowed) case Failure(msg, _, _) => @@ -6975,7 +6968,6 @@ trait APIMethods600 { case Empty => AbacRuleResultJsonV600(result = false) } - } } yield { (result, HttpCode.`200`(callContext)) } diff --git a/obp-api/src/test/scala/code/api/v6_0_0/AbacRuleTests.scala b/obp-api/src/test/scala/code/api/v6_0_0/AbacRuleTests.scala new file mode 100644 index 0000000000..b1a84cb428 --- /dev/null +++ b/obp-api/src/test/scala/code/api/v6_0_0/AbacRuleTests.scala @@ -0,0 +1,255 @@ +package code.api.v6_0_0 + +import code.api.util.APIUtil.OAuth._ +import code.api.util.ApiRole.{canCreateAbacRule, canExecuteAbacRule} +import code.api.util.ErrorMessages +import code.api.util.ErrorMessages.UserHasMissingRoles +import code.api.v6_0_0.APIMethods600.Implementations6_0_0 +import code.entitlement.Entitlement +import code.setup.DefaultUsers +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 AbacRuleTests extends V600ServerSetup with DefaultUsers { + + override def beforeAll(): Unit = { + super.beforeAll() + } + + override def afterAll(): Unit = { + super.afterAll() + } + + object VersionOfApi extends Tag(ApiVersion.v6_0_0.toString) + object ApiEndpoint1 extends Tag(nameOf(Implementations6_0_0.executeAbacRule)) + object ApiEndpoint2 extends Tag(nameOf(Implementations6_0_0.executeAbacPolicy)) + object ApiEndpoint3 extends Tag(nameOf(Implementations6_0_0.createAbacRule)) + + /** + * Helper to create an ABAC rule via the API and return its ID. + */ + private def createAbacRuleViaApi( + ruleName: String, + ruleCode: String, + policy: String = "account-access", + isActive: Boolean = true, + description: String = "Test rule" + ): String = { + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, canCreateAbacRule.toString) + val createJson = CreateAbacRuleJsonV600( + rule_name = ruleName, + rule_code = ruleCode, + description = description, + policy = policy, + is_active = isActive + ) + val request = (v6_0_0_Request / "management" / "abac-rules").POST <@ (user1) + val response = makePostRequest(request, write(createJson)) + response.code should equal(201) + (response.body \ "abac_rule_id").extract[String] + } + + // ==================== executeAbacRule Tests ==================== + + feature(s"Assuring that endpoint executeAbacRule works as expected - $VersionOfApi") { + + scenario("Anonymous access should be rejected", ApiEndpoint1, VersionOfApi) { + When("We make the request without authentication") + val request = (v6_0_0_Request / "management" / "abac-rules" / "some-rule-id" / "execute").POST + val execJson = ExecuteAbacRuleJsonV600(None, None, None, None, None, None, None, None, None) + val response = makePostRequest(request, write(execJson)) + Then("We should get a 401") + response.code should equal(401) + response.body.extract[ErrorMessage].message should equal(ErrorMessages.AuthenticatedUserIsRequired) + } + + scenario("Authenticated user without CanExecuteAbacRule role should be rejected", ApiEndpoint1, VersionOfApi) { + When("We make the request without the required role") + val request = (v6_0_0_Request / "management" / "abac-rules" / "some-rule-id" / "execute").POST <@ (user1) + val execJson = ExecuteAbacRuleJsonV600(None, None, None, None, None, None, None, None, None) + val response = makePostRequest(request, write(execJson)) + Then("We should get a 403") + response.code should equal(403) + response.body.extract[ErrorMessage].message should equal(UserHasMissingRoles + canExecuteAbacRule) + } + + scenario("Execute a non-existent rule should return error", ApiEndpoint1, VersionOfApi) { + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, canExecuteAbacRule.toString) + When("We execute a rule that does not exist") + val request = (v6_0_0_Request / "management" / "abac-rules" / "non-existent-id" / "execute").POST <@ (user1) + val execJson = ExecuteAbacRuleJsonV600(None, None, None, None, None, None, None, None, None) + val response = makePostRequest(request, write(execJson)) + Then("We should get a 404") + response.code should equal(404) + } + + scenario("Execute an allow-all rule should return true", ApiEndpoint1, VersionOfApi) { + val ruleId = createAbacRuleViaApi("allow-all-test", "true") + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, canExecuteAbacRule.toString) + + When("We execute the allow-all rule") + val request = (v6_0_0_Request / "management" / "abac-rules" / ruleId / "execute").POST <@ (user1) + val execJson = ExecuteAbacRuleJsonV600(None, None, None, None, None, None, None, None, None) + val response = makePostRequest(request, write(execJson)) + + Then("We should get a 200 with result = true") + response.code should equal(200) + val result = response.body.extract[AbacRuleResultJsonV600] + result.result should equal(true) + } + + scenario("Execute a deny-all rule should return false", ApiEndpoint1, VersionOfApi) { + val ruleId = createAbacRuleViaApi("deny-all-test", "false") + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, canExecuteAbacRule.toString) + + When("We execute the deny-all rule") + val request = (v6_0_0_Request / "management" / "abac-rules" / ruleId / "execute").POST <@ (user1) + val execJson = ExecuteAbacRuleJsonV600(None, None, None, None, None, None, None, None, None) + val response = makePostRequest(request, write(execJson)) + + Then("We should get a 200 with result = false") + response.code should equal(200) + val result = response.body.extract[AbacRuleResultJsonV600] + result.result should equal(false) + } + + scenario("Execute rule with explicit authenticated_user_id", ApiEndpoint1, VersionOfApi) { + val ruleId = createAbacRuleViaApi("auth-user-test", "true") + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, canExecuteAbacRule.toString) + + When("We execute the rule providing an explicit authenticated_user_id") + val request = (v6_0_0_Request / "management" / "abac-rules" / ruleId / "execute").POST <@ (user1) + val execJson = ExecuteAbacRuleJsonV600( + authenticated_user_id = Some(resourceUser1.userId), + on_behalf_of_user_id = None, + user_id = None, + bank_id = None, + account_id = None, + view_id = None, + transaction_request_id = None, + transaction_id = None, + customer_id = None + ) + val response = makePostRequest(request, write(execJson)) + + Then("We should get a 200 with result = true") + response.code should equal(200) + val result = response.body.extract[AbacRuleResultJsonV600] + result.result should equal(true) + } + + scenario("Execute an inactive rule should return error", ApiEndpoint1, VersionOfApi) { + val ruleId = createAbacRuleViaApi("inactive-test", "true", isActive = false) + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, canExecuteAbacRule.toString) + + When("We execute the inactive rule") + val request = (v6_0_0_Request / "management" / "abac-rules" / ruleId / "execute").POST <@ (user1) + val execJson = ExecuteAbacRuleJsonV600(None, None, None, None, None, None, None, None, None) + val response = makePostRequest(request, write(execJson)) + + Then("We should get result = false (inactive rule fails)") + response.code should equal(200) + val result = response.body.extract[AbacRuleResultJsonV600] + result.result should equal(false) + } + } + + // ==================== executeAbacPolicy Tests ==================== + + feature(s"Assuring that endpoint executeAbacPolicy works as expected - $VersionOfApi") { + + scenario("Anonymous access should be rejected", ApiEndpoint2, VersionOfApi) { + When("We make the request without authentication") + val request = (v6_0_0_Request / "management" / "abac-policies" / "account-access" / "execute").POST + val execJson = ExecuteAbacRuleJsonV600(None, None, None, None, None, None, None, None, None) + val response = makePostRequest(request, write(execJson)) + Then("We should get a 401") + response.code should equal(401) + response.body.extract[ErrorMessage].message should equal(ErrorMessages.AuthenticatedUserIsRequired) + } + + scenario("Authenticated user without CanExecuteAbacRule role should be rejected", ApiEndpoint2, VersionOfApi) { + When("We make the request without the required role") + val request = (v6_0_0_Request / "management" / "abac-policies" / "account-access" / "execute").POST <@ (user1) + val execJson = ExecuteAbacRuleJsonV600(None, None, None, None, None, None, None, None, None) + val response = makePostRequest(request, write(execJson)) + Then("We should get a 403") + response.code should equal(403) + response.body.extract[ErrorMessage].message should equal(UserHasMissingRoles + canExecuteAbacRule) + } + + scenario("Execute policy with invalid policy name should return error", ApiEndpoint2, VersionOfApi) { + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, canExecuteAbacRule.toString) + When("We execute a non-existent policy") + val request = (v6_0_0_Request / "management" / "abac-policies" / "non-existent-policy" / "execute").POST <@ (user1) + val execJson = ExecuteAbacRuleJsonV600(None, None, None, None, None, None, None, None, None) + val response = makePostRequest(request, write(execJson)) + Then("We should get a 404") + response.code should equal(404) + } + + scenario("Execute policy with no rules should default to allow (true)", ApiEndpoint2, VersionOfApi) { + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, canExecuteAbacRule.toString) + + When("We execute the account-access policy with no rules configured") + val request = (v6_0_0_Request / "management" / "abac-policies" / "account-access" / "execute").POST <@ (user1) + val execJson = ExecuteAbacRuleJsonV600(None, None, None, None, None, None, None, None, None) + val response = makePostRequest(request, write(execJson)) + + Then("We should get a 200 with result = true (default allow when no rules)") + response.code should equal(200) + val result = response.body.extract[AbacRuleResultJsonV600] + result.result should equal(true) + } + + scenario("Execute policy with one allow-all rule should return true", ApiEndpoint2, VersionOfApi) { + createAbacRuleViaApi("policy-allow-test", "true", policy = "account-access") + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, canExecuteAbacRule.toString) + + When("We execute the account-access policy") + val request = (v6_0_0_Request / "management" / "abac-policies" / "account-access" / "execute").POST <@ (user1) + val execJson = ExecuteAbacRuleJsonV600(None, None, None, None, None, None, None, None, None) + val response = makePostRequest(request, write(execJson)) + + Then("We should get a 200 with result = true") + response.code should equal(200) + val result = response.body.extract[AbacRuleResultJsonV600] + result.result should equal(true) + } + + scenario("Execute policy with only deny-all rules should return false", ApiEndpoint2, VersionOfApi) { + createAbacRuleViaApi("policy-deny-test", "false", policy = "account-access") + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, canExecuteAbacRule.toString) + + When("We execute the account-access policy with only deny rules") + val request = (v6_0_0_Request / "management" / "abac-policies" / "account-access" / "execute").POST <@ (user1) + val execJson = ExecuteAbacRuleJsonV600(None, None, None, None, None, None, None, None, None) + val response = makePostRequest(request, write(execJson)) + + Then("We should get a 200 with result = false") + response.code should equal(200) + val result = response.body.extract[AbacRuleResultJsonV600] + result.result should equal(false) + } + + scenario("Execute policy with mixed rules - OR logic means at least one must pass", ApiEndpoint2, VersionOfApi) { + // Create one allow rule and one deny rule for the same policy + createAbacRuleViaApi("policy-mixed-allow", "true", policy = "account-access") + createAbacRuleViaApi("policy-mixed-deny", "false", policy = "account-access") + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, canExecuteAbacRule.toString) + + When("We execute the account-access policy with mixed rules") + val request = (v6_0_0_Request / "management" / "abac-policies" / "account-access" / "execute").POST <@ (user1) + val execJson = ExecuteAbacRuleJsonV600(None, None, None, None, None, None, None, None, None) + val response = makePostRequest(request, write(execJson)) + + Then("We should get a 200 with result = true (OR logic - at least one rule passes)") + response.code should equal(200) + val result = response.body.extract[AbacRuleResultJsonV600] + result.result should equal(true) + } + } +}