From 1a2a12867f7fb35a70b399d61ffa99011a44a1d9 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Mon, 15 Dec 2025 12:14:07 +0100 Subject: [PATCH 1/8] ABAC rules compiles --- .../main/scala/code/api/util/ApiRole.scala | 15 +++++ .../src/main/scala/code/api/util/ApiTag.scala | 1 + .../scala/code/api/v6_0_0/APIMethods600.scala | 6 +- .../code/api/v6_0_0/JSONFactory6.0.0.scala | 57 +++++++++++++++++++ 4 files changed, 76 insertions(+), 3 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 31fee338b..f50c13b48 100644 --- a/obp-api/src/main/scala/code/api/util/ApiRole.scala +++ b/obp-api/src/main/scala/code/api/util/ApiRole.scala @@ -678,6 +678,21 @@ object ApiRole extends MdcLoggable{ case class CanGetViewPermissionsAtAllBanks(requiresBankId: Boolean = false) extends ApiRole lazy val canGetViewPermissionsAtAllBanks = CanGetViewPermissionsAtAllBanks() + case class CanCreateAbacRule(requiresBankId: Boolean = false) extends ApiRole + lazy val canCreateAbacRule = CanCreateAbacRule() + + case class CanGetAbacRule(requiresBankId: Boolean = false) extends ApiRole + lazy val canGetAbacRule = CanGetAbacRule() + + case class CanUpdateAbacRule(requiresBankId: Boolean = false) extends ApiRole + lazy val canUpdateAbacRule = CanUpdateAbacRule() + + case class CanDeleteAbacRule(requiresBankId: Boolean = false) extends ApiRole + lazy val canDeleteAbacRule = CanDeleteAbacRule() + + case class CanExecuteAbacRule(requiresBankId: Boolean = false) extends ApiRole + lazy val canExecuteAbacRule = CanExecuteAbacRule() + case class CanGetSystemLevelDynamicEntities(requiresBankId: Boolean = false) extends ApiRole lazy val canGetSystemLevelDynamicEntities = CanGetSystemLevelDynamicEntities() 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 bac7e907c..864efed1a 100644 --- a/obp-api/src/main/scala/code/api/util/ApiTag.scala +++ b/obp-api/src/main/scala/code/api/util/ApiTag.scala @@ -37,6 +37,7 @@ object ApiTag { val apiTagSystemView = ResourceDocTag("View-System") val apiTagEntitlement = ResourceDocTag("Entitlement") val apiTagRole = ResourceDocTag("Role") + val apiTagABAC = ResourceDocTag("ABAC") val apiTagScope = ResourceDocTag("Scope") val apiTagOwnerRequired = ResourceDocTag("OwnerViewRequired") val apiTagCounterparty = ResourceDocTag("Counterparty") 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 9d9a34d00..2de4757eb 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 @@ -74,12 +74,12 @@ trait APIMethods600 { val Implementations6_0_0 = new Implementations600() - class Implementations600 extends MdcLoggable { + class Implementations600 extends RestHelper with MdcLoggable with AbacRuleEndpoints { val implementedInApiVersion: ScannedApiVersion = ApiVersion.v6_0_0 - private val staticResourceDocs = ArrayBuffer[ResourceDoc]() - def resourceDocs = staticResourceDocs + val staticResourceDocs = ArrayBuffer[ResourceDoc]() + val resourceDocs = staticResourceDocs val apiRelations = ArrayBuffer[ApiRelation]() val codeContext = CodeContext(staticResourceDocs, apiRelations) 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 a627741e7..aee2c3369 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 @@ -290,6 +290,47 @@ case class CustomerWithAttributesJsonV600( customer_attributes: List[CustomerAttributeResponseJsonV300] ) +// ABAC Rule JSON models +case class CreateAbacRuleJsonV600( + rule_name: String, + rule_code: String, + description: String, + is_active: Boolean +) + +case class UpdateAbacRuleJsonV600( + rule_name: String, + rule_code: String, + description: String, + is_active: Boolean +) + +case class AbacRuleJsonV600( + abac_rule_id: String, + rule_name: String, + rule_code: String, + is_active: Boolean, + description: String, + created_by_user_id: String, + updated_by_user_id: String +) + +case class AbacRulesJsonV600(abac_rules: List[AbacRuleJsonV600]) + +case class ExecuteAbacRuleJsonV600( + bank_id: Option[String], + account_id: Option[String], + transaction_id: Option[String], + customer_id: Option[String] +) + +case class AbacRuleResultJsonV600( + rule_id: String, + rule_name: String, + result: Boolean, + message: String +) + object JSONFactory600 extends CustomJsonFormats with MdcLoggable{ def createCurrentUsageJson(rateLimits: List[((Option[Long], Option[Long]), LimitCallPeriod)]): Option[RedisCallLimitJson] = { @@ -735,4 +776,20 @@ case class UpdateViewJsonV600( def createViewsJsonV600(views: List[View]): ViewsJsonV600 = { ViewsJsonV600(views.map(createViewJsonV600)) } + + def createAbacRuleJsonV600(rule: code.abacrule.AbacRule): AbacRuleJsonV600 = { + AbacRuleJsonV600( + abac_rule_id = rule.abacRuleId, + rule_name = rule.ruleName, + rule_code = rule.ruleCode, + is_active = rule.isActive, + description = rule.description, + created_by_user_id = rule.createdByUserId, + updated_by_user_id = rule.updatedByUserId + ) + } + + def createAbacRulesJsonV600(rules: List[code.abacrule.AbacRule]): AbacRulesJsonV600 = { + AbacRulesJsonV600(rules.map(createAbacRuleJsonV600)) + } } From 3bdc3da7f59df871da55b4491acfc70f3d283c70 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Mon, 15 Dec 2025 12:30:51 +0100 Subject: [PATCH 2/8] ABAC rules compiles added files to git --- .../main/scala/code/abacrule/AbacRule.scala | 137 +++++ .../scala/code/abacrule/AbacRuleEngine.scala | 229 +++++++++ .../code/abacrule/AbacRuleExamples.scala | 369 ++++++++++++++ .../src/main/scala/code/abacrule/README.md | 437 ++++++++++++++++ .../code/api/v6_0_0/AbacRuleEndpoints.scala | 482 ++++++++++++++++++ 5 files changed, 1654 insertions(+) create mode 100644 obp-api/src/main/scala/code/abacrule/AbacRule.scala create mode 100644 obp-api/src/main/scala/code/abacrule/AbacRuleEngine.scala create mode 100644 obp-api/src/main/scala/code/abacrule/AbacRuleExamples.scala create mode 100644 obp-api/src/main/scala/code/abacrule/README.md create mode 100644 obp-api/src/main/scala/code/api/v6_0_0/AbacRuleEndpoints.scala diff --git a/obp-api/src/main/scala/code/abacrule/AbacRule.scala b/obp-api/src/main/scala/code/abacrule/AbacRule.scala new file mode 100644 index 000000000..493b60086 --- /dev/null +++ b/obp-api/src/main/scala/code/abacrule/AbacRule.scala @@ -0,0 +1,137 @@ +package code.abacrule + +import code.api.util.APIUtil +import com.openbankproject.commons.model._ +import net.liftweb.common.Box +import net.liftweb.mapper._ +import net.liftweb.util.Helpers.tryo + +import java.util.Date + +trait AbacRule { + def abacRuleId: String + def ruleName: String + def ruleCode: String + def isActive: Boolean + def description: String + def createdByUserId: String + def updatedByUserId: String +} + +class MappedAbacRule extends AbacRule with LongKeyedMapper[MappedAbacRule] with IdPK with CreatedUpdated { + def getSingleton = MappedAbacRule + + object mAbacRuleId extends MappedString(this, 255) { + override def defaultValue = APIUtil.generateUUID() + } + object mRuleName extends MappedString(this, 255) + object mRuleCode extends MappedText(this) + object mIsActive extends MappedBoolean(this) { + override def defaultValue = true + } + object mDescription extends MappedText(this) + object mCreatedByUserId extends MappedString(this, 255) + object mUpdatedByUserId extends MappedString(this, 255) + + override def abacRuleId: String = mAbacRuleId.get + override def ruleName: String = mRuleName.get + override def ruleCode: String = mRuleCode.get + override def isActive: Boolean = mIsActive.get + override def description: String = mDescription.get + override def createdByUserId: String = mCreatedByUserId.get + override def updatedByUserId: String = mUpdatedByUserId.get +} + +object MappedAbacRule extends MappedAbacRule with LongKeyedMetaMapper[MappedAbacRule] { + override def dbIndexes: List[BaseIndex[MappedAbacRule]] = Index(mAbacRuleId) :: Index(mRuleName) :: Index(mCreatedByUserId) :: super.dbIndexes +} + +trait AbacRuleProvider { + def getAbacRuleById(ruleId: String): Box[AbacRule] + def getAbacRuleByName(ruleName: String): Box[AbacRule] + def getAllAbacRules(): List[AbacRule] + def getActiveAbacRules(): List[AbacRule] + def createAbacRule( + ruleName: String, + ruleCode: String, + description: String, + isActive: Boolean, + createdBy: String + ): Box[AbacRule] + def updateAbacRule( + ruleId: String, + ruleName: String, + ruleCode: String, + description: String, + isActive: Boolean, + updatedBy: String + ): Box[AbacRule] + def deleteAbacRule(ruleId: String): Box[Boolean] +} + +object MappedAbacRuleProvider extends AbacRuleProvider { + + override def getAbacRuleById(ruleId: String): Box[AbacRule] = { + MappedAbacRule.find(By(MappedAbacRule.mAbacRuleId, ruleId)) + } + + override def getAbacRuleByName(ruleName: String): Box[AbacRule] = { + MappedAbacRule.find(By(MappedAbacRule.mRuleName, ruleName)) + } + + override def getAllAbacRules(): List[AbacRule] = { + MappedAbacRule.findAll() + } + + override def getActiveAbacRules(): List[AbacRule] = { + MappedAbacRule.findAll(By(MappedAbacRule.mIsActive, true)) + } + + override def createAbacRule( + ruleName: String, + ruleCode: String, + description: String, + isActive: Boolean, + createdBy: String + ): Box[AbacRule] = { + tryo { + MappedAbacRule.create + .mRuleName(ruleName) + .mRuleCode(ruleCode) + .mDescription(description) + .mIsActive(isActive) + .mCreatedByUserId(createdBy) + .mUpdatedByUserId(createdBy) + .saveMe() + } + } + + override def updateAbacRule( + ruleId: String, + ruleName: String, + ruleCode: String, + description: String, + isActive: Boolean, + updatedBy: String + ): Box[AbacRule] = { + for { + rule <- MappedAbacRule.find(By(MappedAbacRule.mAbacRuleId, ruleId)) + updatedRule <- tryo { + rule + .mRuleName(ruleName) + .mRuleCode(ruleCode) + .mDescription(description) + .mIsActive(isActive) + .mUpdatedByUserId(updatedBy) + .saveMe() + } + } yield updatedRule + } + + override def deleteAbacRule(ruleId: String): Box[Boolean] = { + for { + rule <- MappedAbacRule.find(By(MappedAbacRule.mAbacRuleId, ruleId)) + deleted <- tryo(rule.delete_!) + } yield deleted + } +} \ No newline at end of file diff --git a/obp-api/src/main/scala/code/abacrule/AbacRuleEngine.scala b/obp-api/src/main/scala/code/abacrule/AbacRuleEngine.scala new file mode 100644 index 000000000..c11df9f11 --- /dev/null +++ b/obp-api/src/main/scala/code/abacrule/AbacRuleEngine.scala @@ -0,0 +1,229 @@ +package code.abacrule + +import code.api.util.{APIUtil, DynamicUtil} +import code.model.dataAccess.ResourceUser +import com.openbankproject.commons.model._ +import net.liftweb.common.{Box, Empty, Failure, Full} +import net.liftweb.util.Helpers.tryo + +import java.util.concurrent.ConcurrentHashMap +import scala.collection.JavaConverters._ +import scala.collection.concurrent + +/** + * ABAC Rule Engine for compiling and executing Attribute-Based Access Control rules + */ +object AbacRuleEngine { + + // Cache for compiled ABAC rule functions + private val compiledRulesCache: concurrent.Map[String, Box[AbacRuleFunction]] = + new ConcurrentHashMap[String, Box[AbacRuleFunction]]().asScala + + /** + * Type alias for compiled ABAC rule function + * Parameters: User, Option[Bank], Option[Account], Option[Transaction], Option[Customer] + * Returns: Boolean (true = allow access, false = deny access) + */ + type AbacRuleFunction = (User, Option[Bank], Option[BankAccount], Option[Transaction], Option[Customer]) => Boolean + + /** + * 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 + */ + def compileRule(ruleId: String, ruleCode: String): Box[AbacRuleFunction] = { + compiledRulesCache.get(ruleId) match { + case Some(cachedFunction) => cachedFunction + case None => + val compiledFunction = compileRuleInternal(ruleCode) + compiledRulesCache.put(ruleId, compiledFunction) + compiledFunction + } + } + + /** + * Internal method to compile ABAC rule code + */ + 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, _) => + Failure(s"Failed to compile ABAC rule: $msg", exception, Empty) + case Empty => + Failure("Failed to compile ABAC rule: Unknown error") + } + } + + /** + * Build complete Scala code for compilation + */ + private def buildFullRuleCode(ruleCode: String): String = { + s""" + |import com.openbankproject.commons.model._ + |import code.model.dataAccess.ResourceUser + |import net.liftweb.common._ + | + |// ABAC Rule Function + |(user: User, bankOpt: Option[Bank], accountOpt: Option[BankAccount], transactionOpt: Option[Transaction], customerOpt: Option[Customer]) => { + | $ruleCode + |} + |""".stripMargin + } + + /** + * Execute an ABAC rule + * + * @param ruleId The ID of the rule to execute + * @param user The user requesting access + * @param bankOpt Optional bank context + * @param accountOpt Optional account context + * @param transactionOpt Optional transaction context + * @param customerOpt Optional customer context + * @return Box[Boolean] - Full(true) if allowed, Full(false) if denied, Failure on error + */ + def executeRule( + ruleId: String, + user: User, + bankOpt: Option[Bank] = None, + accountOpt: Option[BankAccount] = None, + transactionOpt: Option[Transaction] = None, + customerOpt: Option[Customer] = None + ): Box[Boolean] = { + for { + rule <- MappedAbacRuleProvider.getAbacRuleById(ruleId) + _ <- if (rule.isActive) Full(true) else Failure(s"ABAC Rule ${rule.ruleName} is not active") + compiledFunc <- compileRule(ruleId, rule.ruleCode) + result <- tryo { + // Execute rule function directly + // Note: Sandbox execution can be added later if needed + compiledFunc(user, bankOpt, accountOpt, transactionOpt, customerOpt) + } + } yield result + } + + /** + * Execute multiple ABAC rules (AND logic - all must pass) + * + * @param ruleIds List of rule IDs to execute + * @param user The user requesting access + * @param bankOpt Optional bank context + * @param accountOpt Optional account context + * @param transactionOpt Optional transaction context + * @param customerOpt Optional customer context + * @return Box[Boolean] - Full(true) if all rules pass, Full(false) if any rule fails + */ + def executeRulesAnd( + ruleIds: List[String], + user: User, + bankOpt: Option[Bank] = None, + accountOpt: Option[BankAccount] = None, + transactionOpt: Option[Transaction] = None, + customerOpt: Option[Customer] = None + ): Box[Boolean] = { + if (ruleIds.isEmpty) { + Full(true) // No rules means allow by default + } else { + val results = ruleIds.map { ruleId => + executeRule(ruleId, user, bankOpt, accountOpt, transactionOpt, customerOpt) + } + + // Check if any rule failed + results.find(_.exists(_ == false)) match { + case Some(_) => Full(false) // At least one rule denied access + case None => + // Check if all succeeded + if (results.forall(_.isDefined)) { + Full(true) // All rules passed + } else { + // At least one rule had an error + val errors = results.collect { case Failure(msg, _, _) => msg } + Failure(s"ABAC rule execution errors: ${errors.mkString("; ")}") + } + } + } + } + + /** + * Execute multiple ABAC rules (OR logic - at least one must pass) + * + * @param ruleIds List of rule IDs to execute + * @param user The user requesting access + * @param bankOpt Optional bank context + * @param accountOpt Optional account context + * @param transactionOpt Optional transaction context + * @param customerOpt Optional customer context + * @return Box[Boolean] - Full(true) if any rule passes, Full(false) if all rules fail + */ + def executeRulesOr( + ruleIds: List[String], + user: User, + bankOpt: Option[Bank] = None, + accountOpt: Option[BankAccount] = None, + transactionOpt: Option[Transaction] = None, + customerOpt: Option[Customer] = None + ): Box[Boolean] = { + if (ruleIds.isEmpty) { + Full(false) // No rules means deny by default for OR + } else { + val results = ruleIds.map { ruleId => + executeRule(ruleId, user, bankOpt, accountOpt, transactionOpt, customerOpt) + } + + // Check if any rule passed + results.find(_.exists(_ == true)) match { + case Some(_) => Full(true) // At least one rule allowed access + case None => + // All rules either failed or had errors + if (results.exists(_.isDefined)) { + Full(false) // All rules that executed denied access + } else { + // All rules had errors + val errors = results.collect { case Failure(msg, _, _) => msg } + Failure(s"All ABAC rules failed: ${errors.mkString("; ")}") + } + } + } + } + + /** + * 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 + */ + def validateRuleCode(ruleCode: String): Box[String] = { + compileRuleInternal(ruleCode) match { + case Full(_) => Full("ABAC rule code is valid") + case Failure(msg, _, _) => Failure(s"Invalid ABAC rule code: $msg") + case Empty => Failure("Failed to validate ABAC rule code") + } + } + + /** + * Clear the compiled rules cache + */ + def clearCache(): Unit = { + compiledRulesCache.clear() + } + + /** + * Clear a specific rule from the cache + */ + def clearRuleFromCache(ruleId: String): Unit = { + compiledRulesCache.remove(ruleId) + } + + /** + * Get cache statistics + */ + def getCacheStats(): Map[String, Any] = { + Map( + "cached_rules" -> compiledRulesCache.size, + "rule_ids" -> compiledRulesCache.keys.toList + ) + } +} \ No newline at end of file diff --git a/obp-api/src/main/scala/code/abacrule/AbacRuleExamples.scala b/obp-api/src/main/scala/code/abacrule/AbacRuleExamples.scala new file mode 100644 index 000000000..052e1062c --- /dev/null +++ b/obp-api/src/main/scala/code/abacrule/AbacRuleExamples.scala @@ -0,0 +1,369 @@ +package code.abacrule + +/** + * ABAC Rule Examples + * + * This file contains example ABAC rules that can be used as templates. + * Copy the rule code (the string in quotes) when creating new ABAC rules via the API. + */ +object AbacRuleExamples { + + // ==================== USER-BASED RULES ==================== + + /** + * Example 1: Admin Only Access + * Only users with "admin" in their email address can access + */ + val adminOnlyRule: String = + """user.emailAddress.contains("admin")""" + + /** + * Example 2: Specific User Provider + * Only allow users from a specific authentication provider + */ + val providerCheckRule: String = + """user.provider == "obp"""" + + /** + * Example 3: User Email Domain + * Only allow users from specific email domain + */ + val emailDomainRule: String = + """user.emailAddress.endsWith("@example.com")""" + + /** + * Example 4: User Has Username + * Only allow users who have set a username + */ + val hasUsernameRule: String = + """user.name.nonEmpty""" + + // ==================== BANK-BASED RULES ==================== + + /** + * Example 5: Specific Bank Access + * Only allow access to a specific bank + */ + val specificBankRule: String = + """bankOpt.exists(_.bankId.value == "gh.29.uk")""" + + /** + * Example 6: Bank Short Name Check + * Only allow access to banks with specific short name + */ + val bankShortNameRule: String = + """bankOpt.exists(_.shortName.contains("Example"))""" + + /** + * Example 7: Bank Must Be Present + * Require bank context to be provided + */ + val bankRequiredRule: String = + """bankOpt.isDefined""" + + // ==================== ACCOUNT-BASED RULES ==================== + + /** + * Example 8: High Balance Accounts + * Only allow access to accounts with balance > 10,000 + */ + val highBalanceRule: String = + """accountOpt.exists(account => { + | account.balance.toString.toDoubleOption.exists(_ > 10000.0) + |})""".stripMargin + + /** + * Example 9: Low Balance Accounts + * Only allow access to accounts with balance < 1,000 + */ + val lowBalanceRule: String = + """accountOpt.exists(account => { + | account.balance.toString.toDoubleOption.exists(_ < 1000.0) + |})""".stripMargin + + /** + * Example 10: Specific Currency + * Only allow access to accounts with specific currency + */ + val currencyRule: String = + """accountOpt.exists(_.currency == "EUR")""" + + /** + * Example 11: Account Type Check + * Only allow access to savings accounts + */ + val accountTypeRule: String = + """accountOpt.exists(_.accountType == "SAVINGS")""" + + /** + * Example 12: Account Label Contains + * Only allow access to accounts with specific label + */ + val accountLabelRule: String = + """accountOpt.exists(_.label.contains("VIP"))""" + + // ==================== TRANSACTION-BASED RULES ==================== + + /** + * Example 13: Transaction Amount Limit + * Only allow access to transactions under 1,000 + */ + val transactionLimitRule: String = + """transactionOpt.exists(tx => { + | tx.amount.toString.toDoubleOption.exists(_ < 1000.0) + |})""".stripMargin + + /** + * Example 14: Large Transactions Only + * Only allow access to transactions over 10,000 + */ + val largeTransactionRule: String = + """transactionOpt.exists(tx => { + | tx.amount.toString.toDoubleOption.exists(_ >= 10000.0) + |})""".stripMargin + + /** + * Example 15: Specific Transaction Type + * Only allow access to specific transaction types + */ + val transactionTypeRule: String = + """transactionOpt.exists(_.transactionType == "PAYMENT")""" + + /** + * Example 16: Transaction Currency Check + * Only allow access to transactions in specific currency + */ + val transactionCurrencyRule: String = + """transactionOpt.exists(_.currency == "USD")""" + + // ==================== CUSTOMER-BASED RULES ==================== + + /** + * Example 17: Customer Email Domain + * Only allow access if customer email is from specific domain + */ + val customerEmailDomainRule: String = + """customerOpt.exists(_.email.endsWith("@corporate.com"))""" + + /** + * Example 18: Customer Legal Name Check + * Only allow access to customers with specific name pattern + */ + val customerNameRule: String = + """customerOpt.exists(_.legalName.contains("Corporation"))""" + + /** + * Example 19: Customer Mobile Number Pattern + * Only allow access to customers with specific mobile pattern + */ + val customerMobileRule: String = + """customerOpt.exists(_.mobilePhoneNumber.startsWith("+44"))""" + + // ==================== COMBINED RULES ==================== + + /** + * Example 20: Manager with Bank Context + * Managers can only access specific bank + */ + val managerBankRule: String = + """user.emailAddress.contains("manager") && + |bankOpt.exists(_.bankId.value == "gh.29.uk")""".stripMargin + + /** + * Example 21: High Value Account Access + * Only managers can access high-value accounts + */ + val managerHighValueRule: String = + """user.emailAddress.contains("manager") && + |accountOpt.exists(account => { + | account.balance.toString.toDoubleOption.exists(_ > 50000.0) + |})""".stripMargin + + /** + * Example 22: Auditor Transaction Access + * Auditors can only view completed transactions + */ + val auditorTransactionRule: String = + """user.emailAddress.contains("auditor") && + |transactionOpt.exists(_.status == "COMPLETED")""".stripMargin + + /** + * Example 23: VIP Customer Manager Access + * Only specific managers can access VIP customer accounts + */ + val vipManagerRule: String = + """(user.emailAddress.contains("vip-manager") || user.emailAddress.contains("director")) && + |accountOpt.exists(_.label.contains("VIP"))""".stripMargin + + /** + * Example 24: Multi-Condition Access + * Complex rule with multiple conditions + */ + val complexRule: String = + """user.emailAddress.contains("manager") && + |user.provider == "obp" && + |bankOpt.exists(_.bankId.value == "gh.29.uk") && + |accountOpt.exists(account => { + | account.currency == "GBP" && + | account.balance.toString.toDoubleOption.exists(_ > 5000.0) && + | account.balance.toString.toDoubleOption.exists(_ < 100000.0) + |})""".stripMargin + + // ==================== NEGATIVE RULES (DENY ACCESS) ==================== + + /** + * Example 25: Block Specific User + * Deny access to specific user + */ + val blockUserRule: String = + """!user.emailAddress.contains("blocked@example.com")""" + + /** + * Example 26: Block Inactive Accounts + * Deny access to inactive accounts + */ + val blockInactiveAccountRule: String = + """accountOpt.forall(_.accountRoutings.nonEmpty)""" + + /** + * Example 27: Block Small Transactions + * Deny access to transactions under 10 + */ + val blockSmallTransactionRule: String = + """transactionOpt.forall(tx => { + | tx.amount.toString.toDoubleOption.exists(_ >= 10.0) + |})""".stripMargin + + // ==================== ADVANCED RULES ==================== + + /** + * Example 28: Pattern Matching on User Email + * Use regex-like pattern matching + */ + val emailPatternRule: String = + """user.emailAddress.matches(".*@(internal|corporate)\\.com")""" + + /** + * Example 29: Multiple Bank Access + * Allow access to multiple specific banks + */ + val multipleBanksRule: String = + """bankOpt.exists(bank => { + | val allowedBanks = Set("gh.29.uk", "de.10.de", "us.01.us") + | allowedBanks.contains(bank.bankId.value) + |})""".stripMargin + + /** + * Example 30: Balance Range Check + * Only allow access to accounts within balance range + */ + val balanceRangeRule: String = + """accountOpt.exists(account => { + | account.balance.toString.toDoubleOption.exists(balance => + | balance >= 1000.0 && balance <= 50000.0 + | ) + |})""".stripMargin + + /** + * Example 31: OR Logic - Multiple Valid Conditions + * Allow access if any condition is true + */ + val orLogicRule: String = + """user.emailAddress.contains("admin") || + |user.emailAddress.contains("manager") || + |user.emailAddress.contains("director")""".stripMargin + + /** + * Example 32: Nested Option Handling + * Safe navigation through optional values + */ + val nestedOptionRule: String = + """bankOpt.isDefined && + |accountOpt.isDefined && + |accountOpt.exists(_.accountRoutings.nonEmpty)""".stripMargin + + /** + * Example 33: Default to True (Allow All) + * Simple rule that always grants access (useful for testing) + */ + val allowAllRule: String = """true""" + + /** + * Example 34: Default to False (Deny All) + * Simple rule that always denies access + */ + val denyAllRule: String = """false""" + + /** + * Example 35: Context-Aware Rule + * Different logic based on what context is available + */ + val contextAwareRule: String = + """if (transactionOpt.isDefined) { + | // If transaction context exists, apply transaction rules + | transactionOpt.exists(tx => + | tx.amount.toString.toDoubleOption.exists(_ < 10000.0) + | ) + |} else if (accountOpt.isDefined) { + | // If only account context exists, apply account rules + | accountOpt.exists(account => + | account.balance.toString.toDoubleOption.exists(_ > 1000.0) + | ) + |} else { + | // Default case + | user.emailAddress.contains("admin") + |}""".stripMargin + + // ==================== HELPER FUNCTIONS ==================== + + /** + * Get all example rules as a map + */ + def getAllExamples: Map[String, String] = Map( + "admin_only" -> adminOnlyRule, + "provider_check" -> providerCheckRule, + "email_domain" -> emailDomainRule, + "has_username" -> hasUsernameRule, + "specific_bank" -> specificBankRule, + "bank_short_name" -> bankShortNameRule, + "bank_required" -> bankRequiredRule, + "high_balance" -> highBalanceRule, + "low_balance" -> lowBalanceRule, + "currency" -> currencyRule, + "account_type" -> accountTypeRule, + "account_label" -> accountLabelRule, + "transaction_limit" -> transactionLimitRule, + "large_transaction" -> largeTransactionRule, + "transaction_type" -> transactionTypeRule, + "transaction_currency" -> transactionCurrencyRule, + "customer_email_domain" -> customerEmailDomainRule, + "customer_name" -> customerNameRule, + "customer_mobile" -> customerMobileRule, + "manager_bank" -> managerBankRule, + "manager_high_value" -> managerHighValueRule, + "auditor_transaction" -> auditorTransactionRule, + "vip_manager" -> vipManagerRule, + "complex" -> complexRule, + "block_user" -> blockUserRule, + "block_inactive_account" -> blockInactiveAccountRule, + "block_small_transaction" -> blockSmallTransactionRule, + "email_pattern" -> emailPatternRule, + "multiple_banks" -> multipleBanksRule, + "balance_range" -> balanceRangeRule, + "or_logic" -> orLogicRule, + "nested_option" -> nestedOptionRule, + "allow_all" -> allowAllRule, + "deny_all" -> denyAllRule, + "context_aware" -> contextAwareRule + ) + + /** + * Get example by name + */ + def getExample(name: String): Option[String] = getAllExamples.get(name) + + /** + * List all available example names + */ + def listExampleNames: List[String] = getAllExamples.keys.toList.sorted +} \ No newline at end of file diff --git a/obp-api/src/main/scala/code/abacrule/README.md b/obp-api/src/main/scala/code/abacrule/README.md new file mode 100644 index 000000000..f845490be --- /dev/null +++ b/obp-api/src/main/scala/code/abacrule/README.md @@ -0,0 +1,437 @@ +# ABAC Rules Engine + +## Overview + +The ABAC (Attribute-Based Access Control) Rules Engine allows you to create, compile, and execute dynamic access control rules using Scala functions. This provides flexible, fine-grained access control based on attributes of users, banks, accounts, transactions, and customers. + +## Architecture + +### Components + +1. **AbacRule** - Data model for storing ABAC rules +2. **AbacRuleProvider** - Provider interface for CRUD operations on rules +3. **AbacRuleEngine** - Compiler and executor for ABAC rules +4. **AbacRuleEndpoints** - REST API endpoints for managing and executing rules + +### Rule Function Signature + +Each ABAC rule is a Scala function with the following signature: + +```scala +( + user: User, + bankOpt: Option[Bank], + accountOpt: Option[BankAccount], + transactionOpt: Option[Transaction], + customerOpt: Option[Customer] +) => Boolean +``` + +**Returns:** +- `true` - Access is granted +- `false` - Access is denied + +## API Endpoints + +All ABAC endpoints are under `/obp/v6.0.0/management/abac-rules` and require authentication. + +### 1. Create ABAC Rule +**POST** `/management/abac-rules` + +**Role Required:** `CanCreateAbacRule` + +**Request Body:** +```json +{ + "rule_name": "admin_only", + "rule_code": "user.emailAddress.contains(\"admin\")", + "description": "Only allow access to users with admin email", + "is_active": true +} +``` + +**Response:** (201 Created) +```json +{ + "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", + "created_by_user_id": "user123", + "updated_by_user_id": "user123" +} +``` + +### 2. Get ABAC Rule +**GET** `/management/abac-rules/{ABAC_RULE_ID}` + +**Role Required:** `CanGetAbacRule` + +### 3. Get All ABAC Rules +**GET** `/management/abac-rules` + +**Role Required:** `CanGetAbacRule` + +### 4. Update ABAC Rule +**PUT** `/management/abac-rules/{ABAC_RULE_ID}` + +**Role Required:** `CanUpdateAbacRule` + +### 5. Delete ABAC Rule +**DELETE** `/management/abac-rules/{ABAC_RULE_ID}` + +**Role Required:** `CanDeleteAbacRule` + +### 6. Execute ABAC Rule +**POST** `/management/abac-rules/{ABAC_RULE_ID}/execute` + +**Role Required:** `CanExecuteAbacRule` + +**Request Body:** +```json +{ + "bank_id": "gh.29.uk", + "account_id": "8ca8a7e4-6d02-48e3-a029-0b2bf89de9f0", + "transaction_id": null, + "customer_id": null +} +``` + +**Response:** +```json +{ + "rule_id": "abc123", + "rule_name": "admin_only", + "result": true, + "message": "Access granted" +} +``` + +## Rule Examples + +### Example 1: Admin-Only Access +Only users with "admin" in their email can access: +```scala +user.emailAddress.contains("admin") +``` + +### Example 2: High Balance Accounts +Only allow access to accounts with balance > 10,000: +```scala +accountOpt.exists(account => { + account.balance.toString.toDoubleOption.exists(_ > 10000.0) +}) +``` + +### Example 3: Specific Bank Access +Only allow access to a specific bank: +```scala +bankOpt.exists(_.bankId.value == "gh.29.uk") +``` + +### Example 4: Transaction Amount Limit +Only allow access to transactions under 1,000: +```scala +transactionOpt.exists(tx => { + tx.amount.toString.toDoubleOption.exists(_ < 1000.0) +}) +``` + +### Example 5: Customer Email Domain +Only allow access if customer email is from a specific domain: +```scala +customerOpt.exists(_.email.endsWith("@example.com")) +``` + +### Example 6: Combined Rules +Multiple conditions combined: +```scala +user.emailAddress.contains("manager") && +bankOpt.exists(_.bankId.value == "gh.29.uk") && +accountOpt.exists(_.balance.toString.toDoubleOption.exists(_ > 5000.0)) +``` + +### Example 7: User Provider Check +Only allow access from specific authentication provider: +```scala +user.provider == "obp" && user.emailAddress.nonEmpty +``` + +### Example 8: Time-Based Access (using Java time) +Access only during business hours (requires additional imports in the engine): +```scala +{ + val hour = java.time.LocalTime.now().getHour + hour >= 9 && hour <= 17 +} +``` + +## Programmatic Usage + +### Compile a Rule +```scala +import code.abacrule.AbacRuleEngine + +val ruleCode = """user.emailAddress.contains("admin")""" +val compiled = AbacRuleEngine.compileRule("rule123", ruleCode) +``` + +### Execute a Rule +```scala +import code.abacrule.AbacRuleEngine +import com.openbankproject.commons.model._ + +val result = AbacRuleEngine.executeRule( + ruleId = "rule123", + user = currentUser, + bankOpt = Some(bank), + accountOpt = Some(account), + transactionOpt = None, + customerOpt = None +) + +result match { + case Full(true) => println("Access granted") + case Full(false) => println("Access denied") + case Failure(msg, _, _) => println(s"Error: $msg") + case Empty => println("Rule not found") +} +``` + +### Execute Multiple Rules (AND Logic) +All rules must pass: +```scala +val result = AbacRuleEngine.executeRulesAnd( + ruleIds = List("rule1", "rule2", "rule3"), + user = currentUser, + bankOpt = Some(bank) +) +``` + +### Execute Multiple Rules (OR Logic) +At least one rule must pass: +```scala +val result = AbacRuleEngine.executeRulesOr( + ruleIds = List("rule1", "rule2", "rule3"), + user = currentUser, + bankOpt = Some(bank) +) +``` + +### Validate Rule Code +```scala +val validation = AbacRuleEngine.validateRuleCode(ruleCode) +validation match { + case Full(msg) => println(s"Valid: $msg") + case Failure(msg, _, _) => println(s"Invalid: $msg") + case Empty => println("Validation failed") +} +``` + +### Cache Management +```scala +// Clear entire cache +AbacRuleEngine.clearCache() + +// Clear specific rule +AbacRuleEngine.clearRuleFromCache("rule123") + +// Get cache statistics +val stats = AbacRuleEngine.getCacheStats() +println(s"Cached rules: ${stats("cached_rules")}") +``` + +## Security Considerations + +### Sandboxing +The ABAC engine can execute rules in a sandboxed environment with restricted permissions. Configure via: +```properties +dynamic_code_sandbox_permissions=[] +``` + +### Code Validation +All rule code is compiled before execution. Invalid Scala code will be rejected at creation/update time. + +### Best Practices + +1. **Test Rules Before Activating**: Use the execute endpoint to test rules with sample data +2. **Keep Rules Simple**: Complex logic is harder to debug and maintain +3. **Use Descriptive Names**: Name rules clearly to indicate their purpose +4. **Document Rules**: Use the description field to explain what the rule does +5. **Review Regularly**: Audit active rules periodically +6. **Version Control**: Keep rule code in version control alongside application code +7. **Fail-Safe**: Consider what happens if a rule fails - default to deny access + +## Performance + +### Compilation Caching +- Compiled rules are cached in memory +- Cache is automatically populated on first execution +- Cache is cleared when rules are updated or deleted +- Manual cache clearing available via `AbacRuleEngine.clearCache()` + +### Execution Performance +- First execution: ~100-500ms (compilation + execution) +- Subsequent executions: ~1-10ms (cached execution) + +## Database Schema + +The `MappedAbacRule` table stores: + +| Column | Type | Description | +|--------|------|-------------| +| id | Long | Primary key | +| mAbacRuleId | String(255) | Unique UUID | +| mRuleName | String(255) | Human-readable name | +| mRuleCode | Text | Scala function code | +| mIsActive | Boolean | Whether rule is active | +| mDescription | Text | Rule description | +| mCreatedByUserId | String(255) | User ID who created rule | +| mUpdatedByUserId | String(255) | User ID who last updated rule | +| createdAt | Timestamp | Creation timestamp | +| updatedAt | Timestamp | Last update timestamp | + +Indexes: +- `mAbacRuleId` (unique) +- `mRuleName` + +## Error Handling + +### Common Errors + +**Compilation Errors:** +``` +Failed to compile ABAC rule: not found: value accountBalanc +``` +→ Fix typos in rule code + +**Runtime Errors:** +``` +Execution error: java.lang.NullPointerException +``` +→ Use safe navigation with `Option` types + +**Inactive Rule:** +``` +ABAC Rule admin_only is not active +``` +→ Set `is_active: true` when creating/updating + +### Safe Code Patterns + +❌ **Unsafe:** +```scala +account.balance.toString.toDouble > 1000.0 +``` + +✅ **Safe:** +```scala +accountOpt.exists(_.balance.toString.toDoubleOption.exists(_ > 1000.0)) +``` + +## Integration Examples + +### Protecting an Endpoint +```scala +// In your endpoint implementation +for { + (Full(user), callContext) <- authenticatedAccess(cc) + (bank, callContext) <- NewStyle.function.getBank(bankId, callContext) + (account, callContext) <- NewStyle.function.getBankAccount(bankId, accountId, callContext) + + // Check ABAC rules + allowed <- Future { + AbacRuleEngine.executeRulesAnd( + ruleIds = List("bank_access_rule", "account_limit_rule"), + user = user, + bankOpt = Some(bank), + accountOpt = Some(account) + ) + } map { + unboxFullOrFail(_, callContext, "ABAC access check failed", 403) + } + + _ <- Helper.booleanToFuture(s"Access denied by ABAC rules", cc = callContext) { + allowed + } + + // Continue with endpoint logic... +} yield { + // ... +} +``` + +## Roadmap + +Future enhancements: +- [ ] Rule versioning +- [ ] Rule testing framework +- [ ] Rule analytics/logging +- [ ] Rule templates library +- [ ] Visual rule builder UI +- [ ] Rule impact analysis +- [ ] A/B testing for rules +- [ ] Rule scheduling (time-based activation) +- [ ] Rule dependencies/chaining +- [ ] Machine learning-based rule suggestions + +## Technical Implementation Notes + +### Lazy Initialization Pattern + +The `AbacRuleEndpoints` trait uses lazy initialization to avoid `NullPointerException` during startup: + +```scala +// Lazy initialization block - called when first endpoint is accessed +private lazy val abacResourceDocsRegistered: Boolean = { + registerAbacResourceDocs() + true +} + +lazy val createAbacRule: OBPEndpoint = { + case "management" :: "abac-rules" :: Nil JsonPost json -> _ => { + abacResourceDocsRegistered // Triggers initialization + // ... endpoint implementation + } +} +``` + +**Why this is needed:** +- Traits are initialized before concrete classes +- `implementedInApiVersion` is provided by the mixing class +- Without lazy initialization, `ResourceDoc` creation would fail with null API version +- Lazy initialization ensures all values are set before first use + +### Timestamp Fields + +The `MappedAbacRule` class uses Lift's `CreatedUpdated` trait which automatically provides: +- `createdAt`: Timestamp when rule was created +- `updatedAt`: Timestamp when rule was last updated + +These fields are: +- ✅ Stored in the database +- ✅ Automatically managed by Lift Mapper +- ❌ Not exposed in JSON responses (to keep API responses clean) +- ✅ Available internally for auditing + +The JSON response only includes `created_by_user_id` and `updated_by_user_id` for tracking who modified the rule. + +### Thread Safety + +- **Rule Compilation**: Synchronized via ConcurrentHashMap +- **Cache Access**: Thread-safe through concurrent collections +- **Lazy Initialization**: Scala's lazy val is thread-safe by default +- **Database Access**: Handled by Lift Mapper's connection pooling + +## Support + +For issues or questions: +- Check the OBP API documentation +- Review existing rules in your deployment +- Test rules using the execute endpoint +- Check logs for compilation/execution errors + +## License + +Open Bank Project - AGPL v3 \ No newline at end of file diff --git a/obp-api/src/main/scala/code/api/v6_0_0/AbacRuleEndpoints.scala b/obp-api/src/main/scala/code/api/v6_0_0/AbacRuleEndpoints.scala new file mode 100644 index 000000000..09b11814b --- /dev/null +++ b/obp-api/src/main/scala/code/api/v6_0_0/AbacRuleEndpoints.scala @@ -0,0 +1,482 @@ +package code.api.v6_0_0 + +import code.abacrule.{AbacRuleEngine, MappedAbacRuleProvider} +import code.api.util.APIUtil._ +import code.api.util.ApiRole._ +import code.api.util.ApiTag._ +import code.api.util.ErrorMessages._ +import code.api.util.FutureUtil.EndpointContext +import code.api.util.NewStyle.HttpCode +import code.api.util.{APIUtil, CallContext, NewStyle} +import code.api.util.APIUtil.CodeContext +import code.api.v6_0_0.JSONFactory600._ +import code.bankconnectors.Connector +import code.model.dataAccess.ResourceUser +import com.github.dwickern.macros.NameOf.nameOf +import com.openbankproject.commons.ExecutionContext.Implicits.global +import com.openbankproject.commons.model._ +import com.openbankproject.commons.util.{ApiVersion, ScannedApiVersion} +import net.liftweb.common.{Box, Empty, Failure, Full} +import net.liftweb.http.rest.RestHelper + +import scala.collection.mutable.ArrayBuffer +import scala.concurrent.Future + +trait AbacRuleEndpoints { + self: RestHelper => + + val implementedInApiVersion: ScannedApiVersion + val resourceDocs: ArrayBuffer[ResourceDoc] + val staticResourceDocs: ArrayBuffer[ResourceDoc] + val codeContext: CodeContext + + // Lazy initialization block - will be called when first endpoint is accessed + private lazy val abacResourceDocsRegistered: Boolean = { + registerAbacResourceDocs() + true + } + + private def registerAbacResourceDocs(): Unit = { + // Create ABAC Rule + staticResourceDocs += ResourceDoc( + createAbacRule, + implementedInApiVersion, + nameOf(createAbacRule), + "POST", + "/management/abac-rules", + "Create ABAC Rule", + s"""Create a new ABAC (Attribute-Based Access Control) rule. + | + |ABAC rules are Scala functions that return a Boolean value indicating whether access should be granted. + | + |The rule function has the following signature: + |```scala + |(user: User, bankOpt: Option[Bank], accountOpt: Option[BankAccount], transactionOpt: Option[Transaction], customerOpt: Option[Customer]) => Boolean + |``` + | + |Example rule code: + |```scala + |// Allow access only if user email contains "admin" + |user.emailAddress.contains("admin") + |``` + | + |```scala + |// Allow access only to accounts with balance > 1000 + |accountOpt.exists(_.balance.toString.toDouble > 1000.0) + |``` + | + |${userAuthenticationMessage(true)} + | + |""".stripMargin, + CreateAbacRuleJsonV600( + rule_name = "admin_only", + rule_code = """user.emailAddress.contains("admin")""", + description = "Only allow access to users with admin email", + is_active = true + ), + 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", + created_by_user_id = "user123", + updated_by_user_id = "user123" + ), + List( + UserNotLoggedIn, + UserHasMissingRoles, + InvalidJsonFormat, + UnknownError + ), + List(apiTagABAC), + Some(List(canCreateAbacRule)) + ) + + // Get ABAC Rule by ID + staticResourceDocs += ResourceDoc( + getAbacRule, + implementedInApiVersion, + nameOf(getAbacRule), + "GET", + "/management/abac-rules/ABAC_RULE_ID", + "Get ABAC Rule", + s"""Get an ABAC rule by its ID. + | + |${userAuthenticationMessage(true)} + | + |""".stripMargin, + EmptyBody, + 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", + created_by_user_id = "user123", + updated_by_user_id = "user123" + ), + List( + UserNotLoggedIn, + UserHasMissingRoles, + UnknownError + ), + List(apiTagABAC), + Some(List(canGetAbacRule)) + ) + + // Get all ABAC Rules + staticResourceDocs += ResourceDoc( + getAbacRules, + implementedInApiVersion, + nameOf(getAbacRules), + "GET", + "/management/abac-rules", + "Get ABAC Rules", + s"""Get all ABAC rules. + | + |${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", + created_by_user_id = "user123", + updated_by_user_id = "user123" + ) + ) + ), + List( + UserNotLoggedIn, + UserHasMissingRoles, + UnknownError + ), + List(apiTagABAC), + Some(List(canGetAbacRule)) + ) + + // Update ABAC Rule + staticResourceDocs += ResourceDoc( + updateAbacRule, + implementedInApiVersion, + nameOf(updateAbacRule), + "PUT", + "/management/abac-rules/ABAC_RULE_ID", + "Update ABAC Rule", + s"""Update an existing ABAC rule. + | + |${userAuthenticationMessage(true)} + | + |""".stripMargin, + UpdateAbacRuleJsonV600( + rule_name = "admin_only_updated", + rule_code = """user.emailAddress.contains("admin") && user.provider == "obp"""", + description = "Only allow access to OBP admin users", + is_active = true + ), + AbacRuleJsonV600( + abac_rule_id = "abc123", + rule_name = "admin_only_updated", + rule_code = """user.emailAddress.contains("admin") && user.provider == "obp"""", + is_active = true, + description = "Only allow access to OBP admin users", + created_by_user_id = "user123", + updated_by_user_id = "user456" + ), + List( + UserNotLoggedIn, + UserHasMissingRoles, + InvalidJsonFormat, + UnknownError + ), + List(apiTagABAC), + Some(List(canUpdateAbacRule)) + ) + + // Delete ABAC Rule + staticResourceDocs += ResourceDoc( + deleteAbacRule, + implementedInApiVersion, + nameOf(deleteAbacRule), + "DELETE", + "/management/abac-rules/ABAC_RULE_ID", + "Delete ABAC Rule", + s"""Delete an ABAC rule. + | + |${userAuthenticationMessage(true)} + | + |""".stripMargin, + EmptyBody, + EmptyBody, + List( + UserNotLoggedIn, + UserHasMissingRoles, + UnknownError + ), + List(apiTagABAC), + Some(List(canDeleteAbacRule)) + ) + + // Execute ABAC Rule + staticResourceDocs += ResourceDoc( + executeAbacRule, + implementedInApiVersion, + nameOf(executeAbacRule), + "POST", + "/management/abac-rules/ABAC_RULE_ID/execute", + "Execute ABAC Rule", + s"""Execute an ABAC rule to test access control. + | + |This endpoint allows you to test an ABAC rule with specific context (bank, account, transaction, customer). + | + |${userAuthenticationMessage(true)} + | + |""".stripMargin, + ExecuteAbacRuleJsonV600( + bank_id = Some("gh.29.uk"), + account_id = Some("8ca8a7e4-6d02-48e3-a029-0b2bf89de9f0"), + transaction_id = None, + customer_id = None + ), + AbacRuleResultJsonV600( + rule_id = "abc123", + rule_name = "admin_only", + result = true, + message = "Access granted" + ), + List( + UserNotLoggedIn, + UserHasMissingRoles, + InvalidJsonFormat, + UnknownError + ), + List(apiTagABAC), + Some(List(canExecuteAbacRule)) + ) + } + + lazy val createAbacRule: OBPEndpoint = { + case "management" :: "abac-rules" :: Nil JsonPost json -> _ => { + abacResourceDocsRegistered // Trigger lazy initialization + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (Full(user), callContext) <- authenticatedAccess(cc) + _ <- NewStyle.function.hasEntitlement("", user.userId, canCreateAbacRule, callContext) + createJson <- NewStyle.function.tryons(s"$InvalidJsonFormat", 400, callContext) { + json.extract[CreateAbacRuleJsonV600] + } + _ <- NewStyle.function.tryons(s"Rule name must not be empty", 400, callContext) { + createJson.rule_name.nonEmpty + } + _ <- NewStyle.function.tryons(s"Rule code must not be empty", 400, callContext) { + createJson.rule_code.nonEmpty + } + // Validate rule code by attempting to compile it + _ <- Future { + AbacRuleEngine.validateRuleCode(createJson.rule_code) + } map { + unboxFullOrFail(_, callContext, s"Invalid ABAC rule code", 400) + } + rule <- Future { + MappedAbacRuleProvider.createAbacRule( + ruleName = createJson.rule_name, + ruleCode = createJson.rule_code, + description = createJson.description, + isActive = createJson.is_active, + createdBy = user.userId + ) + } map { + unboxFullOrFail(_, callContext, s"Could not create ABAC rule", 400) + } + } yield { + (createAbacRuleJsonV600(rule), HttpCode.`201`(callContext)) + } + } + } + + lazy val getAbacRule: OBPEndpoint = { + case "management" :: "abac-rules" :: ruleId :: Nil JsonGet _ => { + abacResourceDocsRegistered // Trigger lazy initialization + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (Full(user), callContext) <- authenticatedAccess(cc) + _ <- NewStyle.function.hasEntitlement("", user.userId, canGetAbacRule, callContext) + rule <- Future { + MappedAbacRuleProvider.getAbacRuleById(ruleId) + } map { + unboxFullOrFail(_, callContext, s"ABAC Rule not found with ID: $ruleId", 404) + } + } yield { + (createAbacRuleJsonV600(rule), HttpCode.`200`(callContext)) + } + } + } + + lazy val getAbacRules: OBPEndpoint = { + case "management" :: "abac-rules" :: Nil JsonGet _ => { + abacResourceDocsRegistered // Trigger lazy initialization + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (Full(user), callContext) <- authenticatedAccess(cc) + _ <- NewStyle.function.hasEntitlement("", user.userId, canGetAbacRule, callContext) + rules <- Future { + MappedAbacRuleProvider.getAllAbacRules() + } + } yield { + (createAbacRulesJsonV600(rules), HttpCode.`200`(callContext)) + } + } + } + + lazy val updateAbacRule: OBPEndpoint = { + case "management" :: "abac-rules" :: ruleId :: Nil JsonPut json -> _ => { + abacResourceDocsRegistered // Trigger lazy initialization + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (Full(user), callContext) <- authenticatedAccess(cc) + _ <- NewStyle.function.hasEntitlement("", user.userId, canUpdateAbacRule, callContext) + updateJson <- NewStyle.function.tryons(s"$InvalidJsonFormat", 400, callContext) { + json.extract[UpdateAbacRuleJsonV600] + } + // Validate rule code by attempting to compile it + _ <- Future { + AbacRuleEngine.validateRuleCode(updateJson.rule_code) + } map { + unboxFullOrFail(_, callContext, s"Invalid ABAC rule code", 400) + } + rule <- Future { + MappedAbacRuleProvider.updateAbacRule( + ruleId = ruleId, + ruleName = updateJson.rule_name, + ruleCode = updateJson.rule_code, + description = updateJson.description, + isActive = updateJson.is_active, + updatedBy = user.userId + ) + } map { + unboxFullOrFail(_, callContext, s"Could not update ABAC rule with ID: $ruleId", 400) + } + // Clear rule from cache after update + _ <- Future { + AbacRuleEngine.clearRuleFromCache(ruleId) + } + } yield { + (createAbacRuleJsonV600(rule), HttpCode.`200`(callContext)) + } + } + } + + lazy val deleteAbacRule: OBPEndpoint = { + case "management" :: "abac-rules" :: ruleId :: Nil JsonDelete _ => { + abacResourceDocsRegistered // Trigger lazy initialization + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (Full(user), callContext) <- authenticatedAccess(cc) + _ <- NewStyle.function.hasEntitlement("", user.userId, canDeleteAbacRule, callContext) + deleted <- Future { + MappedAbacRuleProvider.deleteAbacRule(ruleId) + } map { + unboxFullOrFail(_, callContext, s"Could not delete ABAC rule with ID: $ruleId", 400) + } + // Clear rule from cache after deletion + _ <- Future { + AbacRuleEngine.clearRuleFromCache(ruleId) + } + } yield { + (Full(deleted), HttpCode.`204`(callContext)) + } + } + } + + lazy val executeAbacRule: OBPEndpoint = { + case "management" :: "abac-rules" :: ruleId :: "execute" :: Nil JsonPost json -> _ => { + abacResourceDocsRegistered // Trigger lazy initialization + 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] + } + rule <- Future { + MappedAbacRuleProvider.getAbacRuleById(ruleId) + } map { + unboxFullOrFail(_, callContext, s"ABAC Rule not found with ID: $ruleId", 404) + } + + // Fetch context objects if IDs are provided + bankOpt <- execJson.bank_id match { + case Some(bankId) => NewStyle.function.getBank(BankId(bankId), callContext).map { case (bank, _) => Some(bank) } + case None => Future.successful(None) + } + + accountOpt <- execJson.account_id match { + case Some(accountId) if execJson.bank_id.isDefined => + NewStyle.function.getBankAccount(BankId(execJson.bank_id.get), AccountId(accountId), callContext) + .map { case (account, _) => Some(account) } + case _ => Future.successful(None) + } + + transactionOpt <- execJson.transaction_id match { + case Some(transId) if execJson.bank_id.isDefined && execJson.account_id.isDefined => + NewStyle.function.getTransaction( + BankId(execJson.bank_id.get), + AccountId(execJson.account_id.get), + TransactionId(transId), + callContext + ).map { case (transaction, _) => Some(transaction) }.recover { case _ => None } + case _ => Future.successful(None) + } + + customerOpt <- execJson.customer_id match { + case Some(custId) if execJson.bank_id.isDefined => + NewStyle.function.getCustomerByCustomerId(custId, callContext) + .map { case (customer, _) => Some(customer) }.recover { case _ => None } + case _ => Future.successful(None) + } + + // Execute the rule + result <- Future { + AbacRuleEngine.executeRule( + ruleId = ruleId, + user = user, + bankOpt = bankOpt, + accountOpt = accountOpt, + transactionOpt = transactionOpt, + customerOpt = customerOpt + ) + } map { + case Full(allowed) => + AbacRuleResultJsonV600( + rule_id = ruleId, + rule_name = rule.ruleName, + result = allowed, + message = if (allowed) "Access granted" else "Access denied" + ) + case Failure(msg, _, _) => + AbacRuleResultJsonV600( + rule_id = ruleId, + rule_name = rule.ruleName, + result = false, + message = s"Execution error: $msg" + ) + case Empty => + AbacRuleResultJsonV600( + rule_id = ruleId, + rule_name = rule.ruleName, + result = false, + message = "Execution failed" + ) + } + } yield { + (result, HttpCode.`200`(callContext)) + } + } + } +} \ No newline at end of file From 5772323ea6fe5c6c8ad6ddf629c019d06345d035 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Mon, 15 Dec 2025 12:47:17 +0100 Subject: [PATCH 3/8] HTML page reference --- ideas/HTML_PAGES_REFERENCE.md | 477 ++++++++++++++++++++++++++++++++++ 1 file changed, 477 insertions(+) create mode 100644 ideas/HTML_PAGES_REFERENCE.md diff --git a/ideas/HTML_PAGES_REFERENCE.md b/ideas/HTML_PAGES_REFERENCE.md new file mode 100644 index 000000000..b34272cb3 --- /dev/null +++ b/ideas/HTML_PAGES_REFERENCE.md @@ -0,0 +1,477 @@ +# HTML Pages Reference + +## Overview +This document lists all HTML pages in the OBP-API application and their route mappings. + +--- + +## Main Application Pages + +### 1. Home & Landing Pages + +#### index.html +- **Path:** `/index` +- **File:** `obp-api/src/main/webapp/index.html` +- **Route:** `Menu.i("Home") / "index"` +- **Authentication:** Not required +- **Purpose:** Main landing page for the API + +#### index-en.html +- **Path:** `/index-en` +- **File:** `obp-api/src/main/webapp/index-en.html` +- **Route:** `Menu.i("index-en") / "index-en"` +- **Authentication:** Not required +- **Purpose:** English version of landing page + +#### introduction.html +- **Path:** `/introduction` +- **File:** `obp-api/src/main/webapp/introduction.html` +- **Route:** `Menu.i("Introduction") / "introduction"` +- **Authentication:** Not required +- **Purpose:** Introduction to the API + +--- + +## Authentication & User Management Pages + +### 2. Login & User Information + +#### already-logged-in.html +- **Path:** `/already-logged-in` +- **File:** `obp-api/src/main/webapp/already-logged-in.html` +- **Route:** `Menu("Already Logged In", "Already Logged In") / "already-logged-in"` +- **Authentication:** Not required +- **Purpose:** Shows message when user is already logged in + +#### user-information.html +- **Path:** `/user-information` +- **File:** `obp-api/src/main/webapp/user-information.html` +- **Route:** `Menu("User Information", "User Information") / "user-information"` +- **Authentication:** Not required +- **Purpose:** Displays user information + +### 3. Password Reset + +#### Lost Password / Password Reset (Dynamically Generated) +- **Path:** `/user_mgt/lost_password` (lost password form) +- **Path:** `/user_mgt/reset_password/{TOKEN}` (reset password form) +- **File:** None (dynamically generated by Lift Framework) +- **Route:** Handled by `AuthUser.lostPassword` and `AuthUser.passwordReset` methods +- **Source:** `obp-api/src/main/scala/code/model/dataAccess/AuthUser.scala` +- **Authentication:** Not required (public password reset) +- **Purpose:** Request and reset forgotten passwords +- **Note:** These are not static HTML files but are rendered by Lift's user management system +- **Links from:** + - `oauth/authorize.html` (line 30): "Forgotten password?" link + - `templates-hidden/_login.html` (line 31): "Forgotten password?" link + +**API Endpoint for Password Reset URL:** +- **Path:** `POST /obp/v4.0.0/management/user/reset-password-url` +- **Role Required:** `CanCreateResetPasswordUrl` +- **Purpose:** Programmatically create password reset URLs +- **Property:** Controlled by `ResetPasswordUrlEnabled` (default: false) + +### 4. User Invitation Pages + +#### user-invitation.html +- **Path:** `/user-invitation` +- **File:** `obp-api/src/main/webapp/user-invitation.html` +- **Route:** `Menu("User Invitation", "User Invitation") / "user-invitation"` +- **Authentication:** Not required +- **Purpose:** User invitation form/page + +#### user-invitation-info.html +- **Path:** `/user-invitation-info` +- **File:** `obp-api/src/main/webapp/user-invitation-info.html` +- **Route:** `Menu("User Invitation Info", "User Invitation Info") / "user-invitation-info"` +- **Authentication:** Not required +- **Purpose:** Information about user invitations + +#### user-invitation-invalid.html +- **Path:** `/user-invitation-invalid` +- **File:** `obp-api/src/main/webapp/user-invitation-invalid.html` +- **Route:** `Menu("User Invitation Invalid", "User Invitation Invalid") / "user-invitation-invalid"` +- **Authentication:** Not required +- **Purpose:** Shows when invitation is invalid + +#### user-invitation-warning.html +- **Path:** `/user-invitation-warning` +- **File:** `obp-api/src/main/webapp/user-invitation-warning.html` +- **Route:** `Menu("User Invitation Warning", "User Invitation Warning") / "user-invitation-warning"` +- **Authentication:** Not required +- **Purpose:** Shows warnings about invitations + +--- + +## OAuth & Consent Pages + +### 5. OAuth Flow Pages + +#### oauth/authorize.html +- **Path:** `/oauth/authorize` +- **File:** `obp-api/src/main/webapp/oauth/authorize.html` +- **Route:** `Menu.i("OAuth") / "oauth" / "authorize"` +- **Authentication:** Not required (starts OAuth flow) +- **Purpose:** OAuth authorization page where users approve access + +#### oauth/thanks.html +- **Path:** `/oauth/thanks` (via OAuthWorkedThanks.menu) +- **File:** `obp-api/src/main/webapp/oauth/thanks.html` +- **Route:** `OAuthWorkedThanks.menu` +- **Authentication:** Not required +- **Purpose:** OAuth completion page that performs redirect + +### 6. Consent Management Pages + +#### consent-screen.html +- **Path:** `/consent-screen` +- **File:** `obp-api/src/main/webapp/consent-screen.html` +- **Route:** `Menu("Consent Screen", Helper.i18n("consent.screen")) / "consent-screen" >> AuthUser.loginFirst` +- **Authentication:** **Required** (AuthUser.loginFirst) +- **Purpose:** OAuth consent screen for approving permissions + +#### consents.html +- **Path:** `/consents` +- **File:** `obp-api/src/main/webapp/consents.html` +- **Route:** `Menu.i("Consents") / "consents"` +- **Authentication:** Not required +- **Purpose:** View/manage consents + +### 7. Berlin Group Consent Pages + +#### confirm-bg-consent-request.html +- **Path:** `/confirm-bg-consent-request` +- **File:** `obp-api/src/main/webapp/confirm-bg-consent-request.html` +- **Route:** `Menu.i("confirm-bg-consent-request") / "confirm-bg-consent-request" >> AuthUser.loginFirst` +- **Authentication:** **Required** (AuthUser.loginFirst) +- **Purpose:** Berlin Group consent confirmation + +#### confirm-bg-consent-request-sca.html +- **Path:** `/confirm-bg-consent-request-sca` +- **File:** `obp-api/src/main/webapp/confirm-bg-consent-request-sca.html` +- **Route:** `Menu.i("confirm-bg-consent-request-sca") / "confirm-bg-consent-request-sca" >> AuthUser.loginFirst` +- **Authentication:** **Required** (AuthUser.loginFirst) +- **Purpose:** Berlin Group consent with SCA (Strong Customer Authentication) + +#### confirm-bg-consent-request-redirect-uri.html +- **Path:** `/confirm-bg-consent-request-redirect-uri` +- **File:** `obp-api/src/main/webapp/confirm-bg-consent-request-redirect-uri.html` +- **Route:** `Menu.i("confirm-bg-consent-request-redirect-uri") / "confirm-bg-consent-request-redirect-uri" >> AuthUser.loginFirst` +- **Authentication:** **Required** (AuthUser.loginFirst) +- **Purpose:** Berlin Group consent with redirect URI + +### 8. VRP (Variable Recurring Payments) Consent Pages + +#### confirm-vrp-consent-request.html +- **Path:** `/confirm-vrp-consent-request` +- **File:** `obp-api/src/main/webapp/confirm-vrp-consent-request.html` +- **Route:** `Menu.i("confirm-vrp-consent-request") / "confirm-vrp-consent-request" >> AuthUser.loginFirst` +- **Authentication:** **Required** (AuthUser.loginFirst) +- **Purpose:** VRP consent request confirmation + +#### confirm-vrp-consent.html +- **Path:** `/confirm-vrp-consent` +- **File:** `obp-api/src/main/webapp/confirm-vrp-consent.html` +- **Route:** `Menu.i("confirm-vrp-consent") / "confirm-vrp-consent" >> AuthUser.loginFirst` +- **Authentication:** **Required** (AuthUser.loginFirst) +- **Purpose:** VRP consent confirmation + +--- + +## Developer & Admin Pages + +### 9. Consumer Management + +#### consumer-registration.html +- **Path:** `/consumer-registration` +- **File:** `obp-api/src/main/webapp/consumer-registration.html` +- **Route:** `Menu("Consumer Registration", Helper.i18n("consumer.registration.nav.name")) / "consumer-registration" >> AuthUser.loginFirst` +- **Authentication:** **Required** (AuthUser.loginFirst) +- **Purpose:** Register new API consumers (OAuth applications) + +### 10. Testing & Development + +#### dummy-user-tokens.html +- **Path:** `/dummy-user-tokens` +- **File:** `obp-api/src/main/webapp/dummy-user-tokens.html` +- **Route:** `Menu("Dummy user tokens", "Get Dummy user tokens") / "dummy-user-tokens" >> AuthUser.loginFirst` +- **Authentication:** **Required** (AuthUser.loginFirst) +- **Purpose:** Get dummy user tokens for testing + +#### create-sandbox-account.html +- **Path:** `/create-sandbox-account` +- **File:** `obp-api/src/main/webapp/create-sandbox-account.html` +- **Route:** `Menu("Sandbox Account Creation", "Create Bank Account") / "create-sandbox-account" >> AuthUser.loginFirst` +- **Authentication:** **Required** (AuthUser.loginFirst) +- **Purpose:** Create sandbox accounts for testing +- **Note:** Only available if `allow_sandbox_account_creation=true` in properties + +--- + +## Security & Authentication Context Pages + +### 11. User Authentication Context + +#### add-user-auth-context-update-request.html +- **Path:** `/add-user-auth-context-update-request` +- **File:** `obp-api/src/main/webapp/add-user-auth-context-update-request.html` +- **Route:** `Menu.i("add-user-auth-context-update-request") / "add-user-auth-context-update-request"` +- **Authentication:** Not required +- **Purpose:** Add user authentication context update request + +#### confirm-user-auth-context-update-request.html +- **Path:** `/confirm-user-auth-context-update-request` +- **File:** `obp-api/src/main/webapp/confirm-user-auth-context-update-request.html` +- **Route:** `Menu.i("confirm-user-auth-context-update-request") / "confirm-user-auth-context-update-request"` +- **Authentication:** Not required +- **Purpose:** Confirm user authentication context update + +### 12. OTP (One-Time Password) + +#### otp.html +- **Path:** `/otp` +- **File:** `obp-api/src/main/webapp/otp.html` +- **Route:** `Menu("Validate OTP", "Validate OTP") / "otp" >> AuthUser.loginFirst` +- **Authentication:** **Required** (AuthUser.loginFirst) +- **Purpose:** Validate one-time passwords + +--- + +## Legal & Information Pages + +### 13. Legal Pages + +#### terms-and-conditions.html +- **Path:** `/terms-and-conditions` +- **File:** `obp-api/src/main/webapp/terms-and-conditions.html` +- **Route:** `Menu("Terms and Conditions", "Terms and Conditions") / "terms-and-conditions"` +- **Authentication:** Not required +- **Purpose:** Terms and conditions + +#### privacy-policy.html +- **Path:** `/privacy-policy` +- **File:** `obp-api/src/main/webapp/privacy-policy.html` +- **Route:** `Menu("Privacy Policy", "Privacy Policy") / "privacy-policy"` +- **Authentication:** Not required +- **Purpose:** Privacy policy + +--- + +## Documentation & Reference Pages + +### 14. Documentation + +#### sdks.html +- **Path:** `/sdks` +- **File:** `obp-api/src/main/webapp/sdks.html` +- **Route:** `Menu.i("SDKs") / "sdks"` +- **Authentication:** Not required +- **Purpose:** SDK documentation and downloads + +#### static.html +- **Path:** `/static` +- **File:** `obp-api/src/main/webapp/static.html` +- **Route:** `Menu.i("Static") / "static"` +- **Authentication:** Not required +- **Purpose:** Static resource documentation + +#### main-faq.html +- **Path:** Not directly routed (likely included/embedded) +- **File:** `obp-api/src/main/webapp/main-faq.html` +- **Route:** None (component file) +- **Authentication:** N/A +- **Purpose:** FAQ content + +--- + +## Debug & Testing Pages + +### 15. Debug Pages + +#### debug.html +- **Path:** `/debug` +- **File:** `obp-api/src/main/webapp/debug.html` +- **Route:** `Menu.i("Debug") / "debug"` +- **Authentication:** Not required +- **Purpose:** Main debug page + +#### debug/awake.html +- **Path:** `/debug/awake` +- **File:** `obp-api/src/main/webapp/debug/awake.html` +- **Route:** `Menu.i("awake") / "debug" / "awake"` +- **Authentication:** Not required +- **Purpose:** Test if API is running/responsive + +#### debug/debug-basic.html +- **Path:** `/debug/debug-basic` +- **File:** `obp-api/src/main/webapp/debug/debug-basic.html` +- **Route:** `Menu.i("debug-basic") / "debug" / "debug-basic"` +- **Authentication:** Not required +- **Purpose:** Basic debug information + +#### debug/debug-default-header.html +- **Path:** `/debug/debug-default-header` +- **File:** `obp-api/src/main/webapp/debug/debug-default-header.html` +- **Route:** `Menu.i("debug-default-header") / "debug" / "debug-default-header"` +- **Authentication:** Not required +- **Purpose:** Test default header template + +#### debug/debug-default-footer.html +- **Path:** `/debug/debug-default-footer` +- **File:** `obp-api/src/main/webapp/debug/debug-default-footer.html` +- **Route:** `Menu.i("debug-default-footer") / "debug" / "debug-default-footer"` +- **Authentication:** Not required +- **Purpose:** Test default footer template + +#### debug/debug-localization.html +- **Path:** `/debug/debug-localization` +- **File:** `obp-api/src/main/webapp/debug/debug-localization.html` +- **Route:** `Menu.i("debug-localization") / "debug" / "debug-localization"` +- **Authentication:** Not required +- **Purpose:** Test localization/i18n + +#### debug/debug-plain.html +- **Path:** `/debug/debug-plain` +- **File:** `obp-api/src/main/webapp/debug/debug-plain.html` +- **Route:** `Menu.i("debug-plain") / "debug" / "debug-plain"` +- **Authentication:** Not required +- **Purpose:** Plain debug page without templates + +#### debug/debug-webui.html +- **Path:** `/debug/debug-webui` +- **File:** `obp-api/src/main/webapp/debug/debug-webui.html` +- **Route:** `Menu.i("debug-webui") / "debug" / "debug-webui"` +- **Authentication:** Not required +- **Purpose:** Test WebUI properties + +--- + +## Template Files (Not Directly Accessible) + +### 16. Template Components + +#### templates-hidden/_login.html +- **Path:** N/A (template component) +- **File:** `obp-api/src/main/webapp/templates-hidden/_login.html` +- **Route:** None (included by Lift framework) +- **Purpose:** Login form template component +- **Note:** Contains "Forgotten password?" link to `/user_mgt/lost_password` + +#### templates-hidden/default.html +- **Path:** N/A (template) +- **File:** `obp-api/src/main/webapp/templates-hidden/default.html` +- **Route:** None (Lift framework template) +- **Purpose:** Default page template + +#### templates-hidden/default-en.html +- **Path:** N/A (template) +- **File:** `obp-api/src/main/webapp/templates-hidden/default-en.html` +- **Route:** None (Lift framework template) +- **Purpose:** English default page template + +#### templates-hidden/default-header.html +- **Path:** N/A (template) +- **File:** `obp-api/src/main/webapp/templates-hidden/default-header.html` +- **Route:** None (Lift framework template) +- **Purpose:** Default header template + +#### templates-hidden/default-footer.html +- **Path:** N/A (template) +- **File:** `obp-api/src/main/webapp/templates-hidden/default-footer.html` +- **Route:** None (Lift framework template) +- **Purpose:** Default footer template + +--- + +## Other Pages + +### 17. Miscellaneous + +#### basic.html +- **Path:** Not directly routed (likely used programmatically) +- **File:** `obp-api/src/main/webapp/basic.html` +- **Route:** None found +- **Purpose:** Basic HTML page template + +--- + +## Route Configuration + +All routes are defined in: +- **File:** `obp-api/src/main/scala/bootstrap/liftweb/Boot.scala` +- **Method:** `boot` method in `Boot` class +- **Framework:** Lift Web Framework's SiteMap + +### Authentication Guards + +- `>> AuthUser.loginFirst` - Requires user to be logged in +- `>> Admin.loginFirst` - Requires admin user to be logged in +- No guard - Public access + +### Conditional Routes + +Some routes are conditionally added based on properties: +- Sandbox account creation requires: `allow_sandbox_account_creation=true` + +--- + +## URL Structure + +All pages are served at: +``` +https://[hostname]/[path] +``` + +For example: +- Home page: `https://api.example.com/index` +- OAuth: `https://api.example.com/oauth/authorize` +- Consent: `https://api.example.com/consent-screen` + +--- + +## Summary Statistics + +**Total HTML Files:** 43 +- **Public Pages:** 27 +- **Authenticated Pages:** 13 +- **Template Components:** 5 +- **Debug Pages:** 9 +- **Dynamically Generated:** 2 (password reset pages) + +**Page Categories:** +- Authentication & User Management: 7 pages +- Password Reset: 2 dynamically generated pages +- OAuth & Consent: 9 pages +- Developer & Admin: 3 pages +- Legal & Information: 4 pages +- Documentation: 4 pages +- Debug & Testing: 9 pages +- Templates: 5 files +- Miscellaneous: 2 pages + +--- + +## Notes + +1. **Lift Framework:** The application uses Lift Web Framework for routing and page rendering +2. **SiteMap:** Routes are configured via Lift's SiteMap in Boot.scala +3. **Templates:** Pages in `templates-hidden/` are not directly accessible but are used as layout templates +4. **Localization:** Some pages support internationalization (i18n) via `Helper.i18n()` +5. **Security:** Many pages require authentication via `AuthUser.loginFirst` or `Admin.loginFirst` +6. **OAuth Flow:** The OAuth authorization flow involves multiple pages: authorize → consent-screen → thanks +7. **Consent Types:** Different consent screens for different standards (Berlin Group, VRP, generic OAuth) +8. **Password Reset:** The password reset flow is handled dynamically by Lift's user management system, not static HTML files + - Lost password form: `/user_mgt/lost_password` + - Reset password form: `/user_mgt/reset_password/{TOKEN}` + - Implementation in: `code/model/dataAccess/AuthUser.scala` + +--- + +## Related Files + +- **Boot Configuration:** `obp-api/src/main/scala/bootstrap/liftweb/Boot.scala` +- **Menu Helpers:** Various classes in `code` package +- **Templates:** Lift framework `templates-hidden` directory +- **Static Resources:** JavaScript, CSS, and images in `webapp` directory +- **User Management:** `obp-api/src/main/scala/code/model/dataAccess/AuthUser.scala` (password reset, validation) +- **Password Reset API:** `obp-api/src/main/scala/code/api/v4_0_0/APIMethods400.scala` (resetPasswordUrl endpoint) \ No newline at end of file From ce1d870f1088091195ab32a4f38c33cb1512b90c Mon Sep 17 00:00:00 2001 From: simonredfern Date: Mon, 15 Dec 2025 13:29:15 +0100 Subject: [PATCH 4/8] ABAC in v6.0.0 --- .../scala/code/api/v6_0_0/APIMethods600.scala | 436 +++++++++++++++- .../code/api/v6_0_0/AbacRuleEndpoints.scala | 482 ------------------ 2 files changed, 434 insertions(+), 484 deletions(-) delete mode 100644 obp-api/src/main/scala/code/api/v6_0_0/AbacRuleEndpoints.scala 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 2de4757eb..93f35f132 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 @@ -26,8 +26,9 @@ import code.api.v5_0_0.JSONFactory500 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.{DynamicEntityDiagnosticsJsonV600, DynamicEntityIssueJsonV600, GroupJsonV600, GroupMembershipJsonV600, GroupMembershipsJsonV600, GroupsJsonV600, PostGroupJsonV600, PostGroupMembershipJsonV600, PostResetPasswordUrlJsonV600, PutGroupJsonV600, ReferenceTypeJsonV600, ReferenceTypesJsonV600, ResetPasswordUrlJsonV600, RoleWithEntitlementCountJsonV600, RolesWithEntitlementCountsJsonV600, ScannedApiVersionJsonV600, UpdateViewJsonV600, ValidateUserEmailJsonV600, ValidateUserEmailResponseJsonV600, ViewJsonV600, ViewPermissionJsonV600, ViewPermissionsJsonV600, ViewsJsonV600, createActiveCallLimitsJsonV600, createCallLimitJsonV600, createCurrentUsageJson} +import code.api.v6_0_0.JSONFactory600.{AbacRuleJsonV600, AbacRuleResultJsonV600, AbacRulesJsonV600, CreateAbacRuleJsonV600, DynamicEntityDiagnosticsJsonV600, DynamicEntityIssueJsonV600, ExecuteAbacRuleJsonV600, GroupJsonV600, GroupMembershipJsonV600, GroupMembershipsJsonV600, GroupsJsonV600, PostGroupJsonV600, PostGroupMembershipJsonV600, PostResetPasswordUrlJsonV600, PutGroupJsonV600, ReferenceTypeJsonV600, ReferenceTypesJsonV600, ResetPasswordUrlJsonV600, RoleWithEntitlementCountJsonV600, RolesWithEntitlementCountsJsonV600, ScannedApiVersionJsonV600, UpdateAbacRuleJsonV600, UpdateViewJsonV600, ValidateUserEmailJsonV600, ValidateUserEmailResponseJsonV600, ViewJsonV600, ViewPermissionJsonV600, ViewPermissionsJsonV600, ViewsJsonV600, createAbacRuleJsonV600, createAbacRulesJsonV600, createActiveCallLimitsJsonV600, createCallLimitJsonV600, createCurrentUsageJson} import code.api.v6_0_0.OBPAPI6_0_0 +import code.abacrule.{AbacRuleEngine, MappedAbacRuleProvider} import code.metrics.APIMetrics import code.bankconnectors.LocalMappedConnectorInternal import code.bankconnectors.LocalMappedConnectorInternal._ @@ -74,7 +75,7 @@ trait APIMethods600 { val Implementations6_0_0 = new Implementations600() - class Implementations600 extends RestHelper with MdcLoggable with AbacRuleEndpoints { + class Implementations600 extends RestHelper with MdcLoggable { val implementedInApiVersion: ScannedApiVersion = ApiVersion.v6_0_0 @@ -4138,6 +4139,437 @@ trait APIMethods600 { } } + // ABAC Rule Endpoints + staticResourceDocs += ResourceDoc( + createAbacRule, + implementedInApiVersion, + nameOf(createAbacRule), + "POST", + "/management/abac-rules", + "Create ABAC Rule", + s"""Create a new ABAC (Attribute-Based Access Control) rule. + | + |ABAC rules are Scala functions that return a Boolean value indicating whether access should be granted. + | + |The rule function has the following signature: + |```scala + |(user: User, bankOpt: Option[Bank], accountOpt: Option[BankAccount], transactionOpt: Option[Transaction], customerOpt: Option[Customer]) => Boolean + |``` + | + |Example rule code: + |```scala + |// Allow access only if user email contains "admin" + |user.emailAddress.contains("admin") + |``` + | + |```scala + |// Allow access only to accounts with balance > 1000 + |accountOpt.exists(_.balance.toString.toDouble > 1000.0) + |``` + | + |${userAuthenticationMessage(true)} + | + |""".stripMargin, + CreateAbacRuleJsonV600( + rule_name = "admin_only", + rule_code = """user.emailAddress.contains("admin")""", + description = "Only allow access to users with admin email", + is_active = true + ), + 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", + created_by_user_id = "user123", + updated_by_user_id = "user123" + ), + List( + UserNotLoggedIn, + UserHasMissingRoles, + InvalidJsonFormat, + UnknownError + ), + List(apiTagABAC), + Some(List(canCreateAbacRule)) + ) + + lazy val createAbacRule: OBPEndpoint = { + case "management" :: "abac-rules" :: Nil JsonPost json -> _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (Full(user), callContext) <- authenticatedAccess(cc) + _ <- NewStyle.function.hasEntitlement("", user.userId, canCreateAbacRule, callContext) + createJson <- NewStyle.function.tryons(s"$InvalidJsonFormat", 400, callContext) { + json.extract[CreateAbacRuleJsonV600] + } + _ <- NewStyle.function.tryons(s"Rule name must not be empty", 400, callContext) { + createJson.rule_name.nonEmpty + } + _ <- NewStyle.function.tryons(s"Rule code must not be empty", 400, callContext) { + createJson.rule_code.nonEmpty + } + // Validate rule code by attempting to compile it + _ <- Future { + AbacRuleEngine.validateRuleCode(createJson.rule_code) + } map { + unboxFullOrFail(_, callContext, s"Invalid ABAC rule code", 400) + } + rule <- Future { + MappedAbacRuleProvider.createAbacRule( + ruleName = createJson.rule_name, + ruleCode = createJson.rule_code, + description = createJson.description, + isActive = createJson.is_active, + createdBy = user.userId + ) + } map { + unboxFullOrFail(_, callContext, s"Could not create ABAC rule", 400) + } + } yield { + (createAbacRuleJsonV600(rule), HttpCode.`201`(callContext)) + } + } + } + + staticResourceDocs += ResourceDoc( + getAbacRule, + implementedInApiVersion, + nameOf(getAbacRule), + "GET", + "/management/abac-rules/ABAC_RULE_ID", + "Get ABAC Rule", + s"""Get an ABAC rule by its ID. + | + |${userAuthenticationMessage(true)} + | + |""".stripMargin, + EmptyBody, + 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", + created_by_user_id = "user123", + updated_by_user_id = "user123" + ), + List( + UserNotLoggedIn, + UserHasMissingRoles, + UnknownError + ), + List(apiTagABAC), + Some(List(canGetAbacRule)) + ) + + lazy val getAbacRule: OBPEndpoint = { + case "management" :: "abac-rules" :: ruleId :: Nil JsonGet _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (Full(user), callContext) <- authenticatedAccess(cc) + _ <- NewStyle.function.hasEntitlement("", user.userId, canGetAbacRule, callContext) + rule <- Future { + MappedAbacRuleProvider.getAbacRuleById(ruleId) + } map { + unboxFullOrFail(_, callContext, s"ABAC Rule not found with ID: $ruleId", 404) + } + } yield { + (createAbacRuleJsonV600(rule), HttpCode.`200`(callContext)) + } + } + } + + staticResourceDocs += ResourceDoc( + getAbacRules, + implementedInApiVersion, + nameOf(getAbacRules), + "GET", + "/management/abac-rules", + "Get ABAC Rules", + s"""Get all ABAC rules. + | + |${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", + created_by_user_id = "user123", + updated_by_user_id = "user123" + ) + ) + ), + List( + UserNotLoggedIn, + UserHasMissingRoles, + UnknownError + ), + List(apiTagABAC), + Some(List(canGetAbacRule)) + ) + + lazy val getAbacRules: OBPEndpoint = { + case "management" :: "abac-rules" :: 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.getAllAbacRules() + } + } yield { + (createAbacRulesJsonV600(rules), HttpCode.`200`(callContext)) + } + } + } + + staticResourceDocs += ResourceDoc( + updateAbacRule, + implementedInApiVersion, + nameOf(updateAbacRule), + "PUT", + "/management/abac-rules/ABAC_RULE_ID", + "Update ABAC Rule", + s"""Update an existing ABAC rule. + | + |${userAuthenticationMessage(true)} + | + |""".stripMargin, + UpdateAbacRuleJsonV600( + rule_name = "admin_only_updated", + rule_code = """user.emailAddress.contains("admin") && user.provider == "obp"""", + description = "Only allow access to OBP admin users", + is_active = true + ), + AbacRuleJsonV600( + abac_rule_id = "abc123", + rule_name = "admin_only_updated", + rule_code = """user.emailAddress.contains("admin") && user.provider == "obp"""", + is_active = true, + description = "Only allow access to OBP admin users", + created_by_user_id = "user123", + updated_by_user_id = "user456" + ), + List( + UserNotLoggedIn, + UserHasMissingRoles, + InvalidJsonFormat, + UnknownError + ), + List(apiTagABAC), + Some(List(canUpdateAbacRule)) + ) + + lazy val updateAbacRule: OBPEndpoint = { + case "management" :: "abac-rules" :: ruleId :: Nil JsonPut json -> _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (Full(user), callContext) <- authenticatedAccess(cc) + _ <- NewStyle.function.hasEntitlement("", user.userId, canUpdateAbacRule, callContext) + updateJson <- NewStyle.function.tryons(s"$InvalidJsonFormat", 400, callContext) { + json.extract[UpdateAbacRuleJsonV600] + } + // Validate rule code by attempting to compile it + _ <- Future { + AbacRuleEngine.validateRuleCode(updateJson.rule_code) + } map { + unboxFullOrFail(_, callContext, s"Invalid ABAC rule code", 400) + } + rule <- Future { + MappedAbacRuleProvider.updateAbacRule( + ruleId = ruleId, + ruleName = updateJson.rule_name, + ruleCode = updateJson.rule_code, + description = updateJson.description, + isActive = updateJson.is_active, + updatedBy = user.userId + ) + } map { + unboxFullOrFail(_, callContext, s"Could not update ABAC rule with ID: $ruleId", 400) + } + // Clear rule from cache after update + _ <- Future { + AbacRuleEngine.clearRuleFromCache(ruleId) + } + } yield { + (createAbacRuleJsonV600(rule), HttpCode.`200`(callContext)) + } + } + } + + staticResourceDocs += ResourceDoc( + deleteAbacRule, + implementedInApiVersion, + nameOf(deleteAbacRule), + "DELETE", + "/management/abac-rules/ABAC_RULE_ID", + "Delete ABAC Rule", + s"""Delete an ABAC rule. + | + |${userAuthenticationMessage(true)} + | + |""".stripMargin, + EmptyBody, + EmptyBody, + List( + UserNotLoggedIn, + UserHasMissingRoles, + UnknownError + ), + List(apiTagABAC), + Some(List(canDeleteAbacRule)) + ) + + lazy val deleteAbacRule: OBPEndpoint = { + case "management" :: "abac-rules" :: ruleId :: Nil JsonDelete _ => { + cc => implicit val ec = EndpointContext(Some(cc)) + for { + (Full(user), callContext) <- authenticatedAccess(cc) + _ <- NewStyle.function.hasEntitlement("", user.userId, canDeleteAbacRule, callContext) + deleted <- Future { + MappedAbacRuleProvider.deleteAbacRule(ruleId) + } map { + unboxFullOrFail(_, callContext, s"Could not delete ABAC rule with ID: $ruleId", 400) + } + // Clear rule from cache after deletion + _ <- Future { + AbacRuleEngine.clearRuleFromCache(ruleId) + } + } yield { + (Full(deleted), HttpCode.`204`(callContext)) + } + } + } + + staticResourceDocs += ResourceDoc( + executeAbacRule, + implementedInApiVersion, + nameOf(executeAbacRule), + "POST", + "/management/abac-rules/ABAC_RULE_ID/execute", + "Execute ABAC Rule", + s"""Execute an ABAC rule to test access control. + | + |This endpoint allows you to test an ABAC rule with specific context (bank, account, transaction, customer). + | + |${userAuthenticationMessage(true)} + | + |""".stripMargin, + ExecuteAbacRuleJsonV600( + bank_id = Some("gh.29.uk"), + account_id = Some("8ca8a7e4-6d02-48e3-a029-0b2bf89de9f0"), + transaction_id = None, + customer_id = None + ), + AbacRuleResultJsonV600( + rule_id = "abc123", + rule_name = "admin_only", + result = true, + message = "Access granted" + ), + List( + UserNotLoggedIn, + UserHasMissingRoles, + InvalidJsonFormat, + UnknownError + ), + List(apiTagABAC), + Some(List(canExecuteAbacRule)) + ) + + lazy val executeAbacRule: OBPEndpoint = { + case "management" :: "abac-rules" :: ruleId :: "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] + } + rule <- Future { + MappedAbacRuleProvider.getAbacRuleById(ruleId) + } map { + unboxFullOrFail(_, callContext, s"ABAC Rule not found with ID: $ruleId", 404) + } + + // Fetch context objects if IDs are provided + bankOpt <- execJson.bank_id match { + case Some(bankId) => NewStyle.function.getBank(BankId(bankId), callContext).map { case (bank, _) => Some(bank) } + case None => Future.successful(None) + } + + accountOpt <- execJson.account_id match { + case Some(accountId) if execJson.bank_id.isDefined => + NewStyle.function.getBankAccount(BankId(execJson.bank_id.get), AccountId(accountId), callContext) + .map { case (account, _) => Some(account) } + case _ => Future.successful(None) + } + + transactionOpt <- execJson.transaction_id match { + case Some(transId) if execJson.bank_id.isDefined && execJson.account_id.isDefined => + NewStyle.function.getTransaction( + BankId(execJson.bank_id.get), + AccountId(execJson.account_id.get), + TransactionId(transId), + callContext + ).map { case (transaction, _) => Some(transaction) }.recover { case _ => None } + case _ => Future.successful(None) + } + + customerOpt <- execJson.customer_id match { + case Some(custId) if execJson.bank_id.isDefined => + NewStyle.function.getCustomerByCustomerId(custId, callContext) + .map { case (customer, _) => Some(customer) }.recover { case _ => None } + case _ => Future.successful(None) + } + + // Execute the rule + result <- Future { + AbacRuleEngine.executeRule( + ruleId = ruleId, + user = user, + bankOpt = bankOpt, + accountOpt = accountOpt, + transactionOpt = transactionOpt, + customerOpt = customerOpt + ) + } map { + case Full(allowed) => + AbacRuleResultJsonV600( + rule_id = ruleId, + rule_name = rule.ruleName, + result = allowed, + message = if (allowed) "Access granted" else "Access denied" + ) + case Failure(msg, _, _) => + AbacRuleResultJsonV600( + rule_id = ruleId, + rule_name = rule.ruleName, + result = false, + message = s"Execution error: $msg" + ) + case Empty => + AbacRuleResultJsonV600( + rule_id = ruleId, + rule_name = rule.ruleName, + result = false, + message = "Execution failed" + ) + } + } yield { + (result, HttpCode.`200`(callContext)) + } + } + } + } } diff --git a/obp-api/src/main/scala/code/api/v6_0_0/AbacRuleEndpoints.scala b/obp-api/src/main/scala/code/api/v6_0_0/AbacRuleEndpoints.scala deleted file mode 100644 index 09b11814b..000000000 --- a/obp-api/src/main/scala/code/api/v6_0_0/AbacRuleEndpoints.scala +++ /dev/null @@ -1,482 +0,0 @@ -package code.api.v6_0_0 - -import code.abacrule.{AbacRuleEngine, MappedAbacRuleProvider} -import code.api.util.APIUtil._ -import code.api.util.ApiRole._ -import code.api.util.ApiTag._ -import code.api.util.ErrorMessages._ -import code.api.util.FutureUtil.EndpointContext -import code.api.util.NewStyle.HttpCode -import code.api.util.{APIUtil, CallContext, NewStyle} -import code.api.util.APIUtil.CodeContext -import code.api.v6_0_0.JSONFactory600._ -import code.bankconnectors.Connector -import code.model.dataAccess.ResourceUser -import com.github.dwickern.macros.NameOf.nameOf -import com.openbankproject.commons.ExecutionContext.Implicits.global -import com.openbankproject.commons.model._ -import com.openbankproject.commons.util.{ApiVersion, ScannedApiVersion} -import net.liftweb.common.{Box, Empty, Failure, Full} -import net.liftweb.http.rest.RestHelper - -import scala.collection.mutable.ArrayBuffer -import scala.concurrent.Future - -trait AbacRuleEndpoints { - self: RestHelper => - - val implementedInApiVersion: ScannedApiVersion - val resourceDocs: ArrayBuffer[ResourceDoc] - val staticResourceDocs: ArrayBuffer[ResourceDoc] - val codeContext: CodeContext - - // Lazy initialization block - will be called when first endpoint is accessed - private lazy val abacResourceDocsRegistered: Boolean = { - registerAbacResourceDocs() - true - } - - private def registerAbacResourceDocs(): Unit = { - // Create ABAC Rule - staticResourceDocs += ResourceDoc( - createAbacRule, - implementedInApiVersion, - nameOf(createAbacRule), - "POST", - "/management/abac-rules", - "Create ABAC Rule", - s"""Create a new ABAC (Attribute-Based Access Control) rule. - | - |ABAC rules are Scala functions that return a Boolean value indicating whether access should be granted. - | - |The rule function has the following signature: - |```scala - |(user: User, bankOpt: Option[Bank], accountOpt: Option[BankAccount], transactionOpt: Option[Transaction], customerOpt: Option[Customer]) => Boolean - |``` - | - |Example rule code: - |```scala - |// Allow access only if user email contains "admin" - |user.emailAddress.contains("admin") - |``` - | - |```scala - |// Allow access only to accounts with balance > 1000 - |accountOpt.exists(_.balance.toString.toDouble > 1000.0) - |``` - | - |${userAuthenticationMessage(true)} - | - |""".stripMargin, - CreateAbacRuleJsonV600( - rule_name = "admin_only", - rule_code = """user.emailAddress.contains("admin")""", - description = "Only allow access to users with admin email", - is_active = true - ), - 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", - created_by_user_id = "user123", - updated_by_user_id = "user123" - ), - List( - UserNotLoggedIn, - UserHasMissingRoles, - InvalidJsonFormat, - UnknownError - ), - List(apiTagABAC), - Some(List(canCreateAbacRule)) - ) - - // Get ABAC Rule by ID - staticResourceDocs += ResourceDoc( - getAbacRule, - implementedInApiVersion, - nameOf(getAbacRule), - "GET", - "/management/abac-rules/ABAC_RULE_ID", - "Get ABAC Rule", - s"""Get an ABAC rule by its ID. - | - |${userAuthenticationMessage(true)} - | - |""".stripMargin, - EmptyBody, - 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", - created_by_user_id = "user123", - updated_by_user_id = "user123" - ), - List( - UserNotLoggedIn, - UserHasMissingRoles, - UnknownError - ), - List(apiTagABAC), - Some(List(canGetAbacRule)) - ) - - // Get all ABAC Rules - staticResourceDocs += ResourceDoc( - getAbacRules, - implementedInApiVersion, - nameOf(getAbacRules), - "GET", - "/management/abac-rules", - "Get ABAC Rules", - s"""Get all ABAC rules. - | - |${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", - created_by_user_id = "user123", - updated_by_user_id = "user123" - ) - ) - ), - List( - UserNotLoggedIn, - UserHasMissingRoles, - UnknownError - ), - List(apiTagABAC), - Some(List(canGetAbacRule)) - ) - - // Update ABAC Rule - staticResourceDocs += ResourceDoc( - updateAbacRule, - implementedInApiVersion, - nameOf(updateAbacRule), - "PUT", - "/management/abac-rules/ABAC_RULE_ID", - "Update ABAC Rule", - s"""Update an existing ABAC rule. - | - |${userAuthenticationMessage(true)} - | - |""".stripMargin, - UpdateAbacRuleJsonV600( - rule_name = "admin_only_updated", - rule_code = """user.emailAddress.contains("admin") && user.provider == "obp"""", - description = "Only allow access to OBP admin users", - is_active = true - ), - AbacRuleJsonV600( - abac_rule_id = "abc123", - rule_name = "admin_only_updated", - rule_code = """user.emailAddress.contains("admin") && user.provider == "obp"""", - is_active = true, - description = "Only allow access to OBP admin users", - created_by_user_id = "user123", - updated_by_user_id = "user456" - ), - List( - UserNotLoggedIn, - UserHasMissingRoles, - InvalidJsonFormat, - UnknownError - ), - List(apiTagABAC), - Some(List(canUpdateAbacRule)) - ) - - // Delete ABAC Rule - staticResourceDocs += ResourceDoc( - deleteAbacRule, - implementedInApiVersion, - nameOf(deleteAbacRule), - "DELETE", - "/management/abac-rules/ABAC_RULE_ID", - "Delete ABAC Rule", - s"""Delete an ABAC rule. - | - |${userAuthenticationMessage(true)} - | - |""".stripMargin, - EmptyBody, - EmptyBody, - List( - UserNotLoggedIn, - UserHasMissingRoles, - UnknownError - ), - List(apiTagABAC), - Some(List(canDeleteAbacRule)) - ) - - // Execute ABAC Rule - staticResourceDocs += ResourceDoc( - executeAbacRule, - implementedInApiVersion, - nameOf(executeAbacRule), - "POST", - "/management/abac-rules/ABAC_RULE_ID/execute", - "Execute ABAC Rule", - s"""Execute an ABAC rule to test access control. - | - |This endpoint allows you to test an ABAC rule with specific context (bank, account, transaction, customer). - | - |${userAuthenticationMessage(true)} - | - |""".stripMargin, - ExecuteAbacRuleJsonV600( - bank_id = Some("gh.29.uk"), - account_id = Some("8ca8a7e4-6d02-48e3-a029-0b2bf89de9f0"), - transaction_id = None, - customer_id = None - ), - AbacRuleResultJsonV600( - rule_id = "abc123", - rule_name = "admin_only", - result = true, - message = "Access granted" - ), - List( - UserNotLoggedIn, - UserHasMissingRoles, - InvalidJsonFormat, - UnknownError - ), - List(apiTagABAC), - Some(List(canExecuteAbacRule)) - ) - } - - lazy val createAbacRule: OBPEndpoint = { - case "management" :: "abac-rules" :: Nil JsonPost json -> _ => { - abacResourceDocsRegistered // Trigger lazy initialization - cc => implicit val ec = EndpointContext(Some(cc)) - for { - (Full(user), callContext) <- authenticatedAccess(cc) - _ <- NewStyle.function.hasEntitlement("", user.userId, canCreateAbacRule, callContext) - createJson <- NewStyle.function.tryons(s"$InvalidJsonFormat", 400, callContext) { - json.extract[CreateAbacRuleJsonV600] - } - _ <- NewStyle.function.tryons(s"Rule name must not be empty", 400, callContext) { - createJson.rule_name.nonEmpty - } - _ <- NewStyle.function.tryons(s"Rule code must not be empty", 400, callContext) { - createJson.rule_code.nonEmpty - } - // Validate rule code by attempting to compile it - _ <- Future { - AbacRuleEngine.validateRuleCode(createJson.rule_code) - } map { - unboxFullOrFail(_, callContext, s"Invalid ABAC rule code", 400) - } - rule <- Future { - MappedAbacRuleProvider.createAbacRule( - ruleName = createJson.rule_name, - ruleCode = createJson.rule_code, - description = createJson.description, - isActive = createJson.is_active, - createdBy = user.userId - ) - } map { - unboxFullOrFail(_, callContext, s"Could not create ABAC rule", 400) - } - } yield { - (createAbacRuleJsonV600(rule), HttpCode.`201`(callContext)) - } - } - } - - lazy val getAbacRule: OBPEndpoint = { - case "management" :: "abac-rules" :: ruleId :: Nil JsonGet _ => { - abacResourceDocsRegistered // Trigger lazy initialization - cc => implicit val ec = EndpointContext(Some(cc)) - for { - (Full(user), callContext) <- authenticatedAccess(cc) - _ <- NewStyle.function.hasEntitlement("", user.userId, canGetAbacRule, callContext) - rule <- Future { - MappedAbacRuleProvider.getAbacRuleById(ruleId) - } map { - unboxFullOrFail(_, callContext, s"ABAC Rule not found with ID: $ruleId", 404) - } - } yield { - (createAbacRuleJsonV600(rule), HttpCode.`200`(callContext)) - } - } - } - - lazy val getAbacRules: OBPEndpoint = { - case "management" :: "abac-rules" :: Nil JsonGet _ => { - abacResourceDocsRegistered // Trigger lazy initialization - cc => implicit val ec = EndpointContext(Some(cc)) - for { - (Full(user), callContext) <- authenticatedAccess(cc) - _ <- NewStyle.function.hasEntitlement("", user.userId, canGetAbacRule, callContext) - rules <- Future { - MappedAbacRuleProvider.getAllAbacRules() - } - } yield { - (createAbacRulesJsonV600(rules), HttpCode.`200`(callContext)) - } - } - } - - lazy val updateAbacRule: OBPEndpoint = { - case "management" :: "abac-rules" :: ruleId :: Nil JsonPut json -> _ => { - abacResourceDocsRegistered // Trigger lazy initialization - cc => implicit val ec = EndpointContext(Some(cc)) - for { - (Full(user), callContext) <- authenticatedAccess(cc) - _ <- NewStyle.function.hasEntitlement("", user.userId, canUpdateAbacRule, callContext) - updateJson <- NewStyle.function.tryons(s"$InvalidJsonFormat", 400, callContext) { - json.extract[UpdateAbacRuleJsonV600] - } - // Validate rule code by attempting to compile it - _ <- Future { - AbacRuleEngine.validateRuleCode(updateJson.rule_code) - } map { - unboxFullOrFail(_, callContext, s"Invalid ABAC rule code", 400) - } - rule <- Future { - MappedAbacRuleProvider.updateAbacRule( - ruleId = ruleId, - ruleName = updateJson.rule_name, - ruleCode = updateJson.rule_code, - description = updateJson.description, - isActive = updateJson.is_active, - updatedBy = user.userId - ) - } map { - unboxFullOrFail(_, callContext, s"Could not update ABAC rule with ID: $ruleId", 400) - } - // Clear rule from cache after update - _ <- Future { - AbacRuleEngine.clearRuleFromCache(ruleId) - } - } yield { - (createAbacRuleJsonV600(rule), HttpCode.`200`(callContext)) - } - } - } - - lazy val deleteAbacRule: OBPEndpoint = { - case "management" :: "abac-rules" :: ruleId :: Nil JsonDelete _ => { - abacResourceDocsRegistered // Trigger lazy initialization - cc => implicit val ec = EndpointContext(Some(cc)) - for { - (Full(user), callContext) <- authenticatedAccess(cc) - _ <- NewStyle.function.hasEntitlement("", user.userId, canDeleteAbacRule, callContext) - deleted <- Future { - MappedAbacRuleProvider.deleteAbacRule(ruleId) - } map { - unboxFullOrFail(_, callContext, s"Could not delete ABAC rule with ID: $ruleId", 400) - } - // Clear rule from cache after deletion - _ <- Future { - AbacRuleEngine.clearRuleFromCache(ruleId) - } - } yield { - (Full(deleted), HttpCode.`204`(callContext)) - } - } - } - - lazy val executeAbacRule: OBPEndpoint = { - case "management" :: "abac-rules" :: ruleId :: "execute" :: Nil JsonPost json -> _ => { - abacResourceDocsRegistered // Trigger lazy initialization - 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] - } - rule <- Future { - MappedAbacRuleProvider.getAbacRuleById(ruleId) - } map { - unboxFullOrFail(_, callContext, s"ABAC Rule not found with ID: $ruleId", 404) - } - - // Fetch context objects if IDs are provided - bankOpt <- execJson.bank_id match { - case Some(bankId) => NewStyle.function.getBank(BankId(bankId), callContext).map { case (bank, _) => Some(bank) } - case None => Future.successful(None) - } - - accountOpt <- execJson.account_id match { - case Some(accountId) if execJson.bank_id.isDefined => - NewStyle.function.getBankAccount(BankId(execJson.bank_id.get), AccountId(accountId), callContext) - .map { case (account, _) => Some(account) } - case _ => Future.successful(None) - } - - transactionOpt <- execJson.transaction_id match { - case Some(transId) if execJson.bank_id.isDefined && execJson.account_id.isDefined => - NewStyle.function.getTransaction( - BankId(execJson.bank_id.get), - AccountId(execJson.account_id.get), - TransactionId(transId), - callContext - ).map { case (transaction, _) => Some(transaction) }.recover { case _ => None } - case _ => Future.successful(None) - } - - customerOpt <- execJson.customer_id match { - case Some(custId) if execJson.bank_id.isDefined => - NewStyle.function.getCustomerByCustomerId(custId, callContext) - .map { case (customer, _) => Some(customer) }.recover { case _ => None } - case _ => Future.successful(None) - } - - // Execute the rule - result <- Future { - AbacRuleEngine.executeRule( - ruleId = ruleId, - user = user, - bankOpt = bankOpt, - accountOpt = accountOpt, - transactionOpt = transactionOpt, - customerOpt = customerOpt - ) - } map { - case Full(allowed) => - AbacRuleResultJsonV600( - rule_id = ruleId, - rule_name = rule.ruleName, - result = allowed, - message = if (allowed) "Access granted" else "Access denied" - ) - case Failure(msg, _, _) => - AbacRuleResultJsonV600( - rule_id = ruleId, - rule_name = rule.ruleName, - result = false, - message = s"Execution error: $msg" - ) - case Empty => - AbacRuleResultJsonV600( - rule_id = ruleId, - rule_name = rule.ruleName, - result = false, - message = "Execution failed" - ) - } - } yield { - (result, HttpCode.`200`(callContext)) - } - } - } -} \ No newline at end of file From f785d7eab3b25514ce4ae03a2badafc9599323eb Mon Sep 17 00:00:00 2001 From: simonredfern Date: Mon, 15 Dec 2025 13:40:03 +0100 Subject: [PATCH 5/8] ABAC in v6.0.0 2 --- obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala | 3 ++- 1 file changed, 2 insertions(+), 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 93f35f132..e60580939 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 @@ -26,7 +26,8 @@ import code.api.v5_0_0.JSONFactory500 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.{AbacRuleJsonV600, AbacRuleResultJsonV600, AbacRulesJsonV600, CreateAbacRuleJsonV600, DynamicEntityDiagnosticsJsonV600, DynamicEntityIssueJsonV600, ExecuteAbacRuleJsonV600, GroupJsonV600, GroupMembershipJsonV600, GroupMembershipsJsonV600, GroupsJsonV600, PostGroupJsonV600, PostGroupMembershipJsonV600, PostResetPasswordUrlJsonV600, PutGroupJsonV600, ReferenceTypeJsonV600, ReferenceTypesJsonV600, ResetPasswordUrlJsonV600, RoleWithEntitlementCountJsonV600, RolesWithEntitlementCountsJsonV600, ScannedApiVersionJsonV600, UpdateAbacRuleJsonV600, UpdateViewJsonV600, ValidateUserEmailJsonV600, ValidateUserEmailResponseJsonV600, ViewJsonV600, ViewPermissionJsonV600, ViewPermissionsJsonV600, ViewsJsonV600, createAbacRuleJsonV600, createAbacRulesJsonV600, createActiveCallLimitsJsonV600, createCallLimitJsonV600, createCurrentUsageJson} +import code.api.v6_0_0.JSONFactory600.{DynamicEntityDiagnosticsJsonV600, DynamicEntityIssueJsonV600, GroupJsonV600, GroupMembershipJsonV600, GroupMembershipsJsonV600, GroupsJsonV600, PostGroupJsonV600, PostGroupMembershipJsonV600, PostResetPasswordUrlJsonV600, PutGroupJsonV600, ReferenceTypeJsonV600, ReferenceTypesJsonV600, ResetPasswordUrlJsonV600, RoleWithEntitlementCountJsonV600, RolesWithEntitlementCountsJsonV600, ScannedApiVersionJsonV600, UpdateViewJsonV600, ValidateUserEmailJsonV600, ValidateUserEmailResponseJsonV600, ViewJsonV600, ViewPermissionJsonV600, ViewPermissionsJsonV600, ViewsJsonV600, createAbacRuleJsonV600, createAbacRulesJsonV600, createActiveCallLimitsJsonV600, createCallLimitJsonV600, createCurrentUsageJson} +import code.api.v6_0_0.{AbacRuleJsonV600, AbacRuleResultJsonV600, AbacRulesJsonV600, CreateAbacRuleJsonV600, ExecuteAbacRuleJsonV600, UpdateAbacRuleJsonV600} import code.api.v6_0_0.OBPAPI6_0_0 import code.abacrule.{AbacRuleEngine, MappedAbacRuleProvider} import code.metrics.APIMetrics From cd3364f03976a8b8e06025c05969cac16670d455 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Mon, 15 Dec 2025 13:54:11 +0100 Subject: [PATCH 6/8] Add ViewPermissionsTest.scala --- .../code/api/v6_0_0/ViewPermissionsTest.scala | 143 ++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 obp-api/src/test/scala/code/api/v6_0_0/ViewPermissionsTest.scala diff --git a/obp-api/src/test/scala/code/api/v6_0_0/ViewPermissionsTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/ViewPermissionsTest.scala new file mode 100644 index 000000000..ebd54c8dd --- /dev/null +++ b/obp-api/src/test/scala/code/api/v6_0_0/ViewPermissionsTest.scala @@ -0,0 +1,143 @@ +package code.api.v6_0_0 + +import code.api.util.APIUtil.OAuth._ +import code.api.util.ApiRole.CanGetViewPermissionsAtAllBanks +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 org.scalatest.Tag + +class ViewPermissionsTest extends V600ServerSetup with DefaultUsers { + + override def beforeAll(): Unit = { + super.beforeAll() + } + + override def afterAll(): Unit = { + super.afterAll() + } + + /** + * Test tags + * Example: To run tests with tag "getViewPermissions": + * 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.getViewPermissions)) + + feature(s"Test GET /management/view-permissions endpoint - $VersionOfApi") { + + scenario("We try to get view permissions - Anonymous access", ApiEndpoint1, VersionOfApi) { + When("We make the request without authentication") + val request = (v6_0_0_Request / "management" / "view-permissions").GET + val response = makeGetRequest(request) + Then("We should get a 401 - User Not Logged In") + response.code should equal(401) + response.body.extract[ErrorMessage].message should equal(ErrorMessages.UserNotLoggedIn) + } + + scenario("We try to get view permissions without proper role - Authorized access", ApiEndpoint1, VersionOfApi) { + When("We make the request as user1 without the CanGetViewPermissionsAtAllBanks role") + val request = (v6_0_0_Request / "management" / "view-permissions").GET <@ (user1) + val response = makeGetRequest(request) + Then("We should get a 403 - Missing Required Role") + response.code should equal(403) + And("Error message should indicate missing CanGetViewPermissionsAtAllBanks role") + response.body.extract[ErrorMessage].message should equal(UserHasMissingRoles + CanGetViewPermissionsAtAllBanks) + } + + scenario("We try to get view permissions with proper role - Authorized access", ApiEndpoint1, VersionOfApi) { + When("We grant the CanGetViewPermissionsAtAllBanks role to user1") + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanGetViewPermissionsAtAllBanks.toString) + + And("We make the request as user1 with the CanGetViewPermissionsAtAllBanks role") + val request = (v6_0_0_Request / "management" / "view-permissions").GET <@ (user1) + val response = makeGetRequest(request) + + Then("We should get a 200 - Success") + response.code should equal(200) + + And("Response should contain a permissions array") + val json = response.body + val permissionsArray = (json \ "permissions").children + permissionsArray.size should be > 0 + + And("Each permission should have permission and category fields") + permissionsArray.foreach { permission => + (permission \ "permission").values.toString should not be empty + (permission \ "category").values.toString should not be empty + } + + And("Permissions should include standard view permissions") + val permissionNames = permissionsArray.map(p => (p \ "permission").values.toString) + permissionNames should contain("can_see_transaction_amount") + permissionNames should contain("can_see_bank_account_balance") + permissionNames should contain("can_create_custom_view") + permissionNames should contain("can_grant_access_to_views") + + And("Permissions should have appropriate categories") + val categories = permissionsArray.map(p => (p \ "category").values.toString).distinct + categories.size should be > 0 + } + + scenario("Verify all permission constants are included", ApiEndpoint1, VersionOfApi) { + When("We grant the CanGetViewPermissionsAtAllBanks role to user1") + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, CanGetViewPermissionsAtAllBanks.toString) + + And("We make the request as user1") + val request = (v6_0_0_Request / "management" / "view-permissions").GET <@ (user1) + val response = makeGetRequest(request) + + Then("Response should include all key permissions") + val json = response.body + val permissionNames = (json \ "permissions").children.map(p => (p \ "permission").values.toString) + + // Transaction permissions + permissionNames should contain("can_see_transaction_this_bank_account") + permissionNames should contain("can_see_transaction_other_bank_account") + permissionNames should contain("can_see_transaction_metadata") + permissionNames should contain("can_see_transaction_description") + + // Account permissions + permissionNames should contain("can_see_bank_account_owners") + permissionNames should contain("can_see_bank_account_iban") + permissionNames should contain("can_see_bank_account_number") + permissionNames should contain("can_update_bank_account_label") + + // Counterparty permissions + permissionNames should contain("can_see_other_account_iban") + permissionNames should contain("can_add_counterparty") + permissionNames should contain("can_delete_counterparty") + + // Metadata permissions + permissionNames should contain("can_see_comments") + permissionNames should contain("can_add_comment") + permissionNames should contain("can_see_tags") + permissionNames should contain("can_add_tag") + + // Transaction Request permissions + permissionNames should contain("can_add_transaction_request_to_own_account") + permissionNames should contain("can_add_transaction_request_to_any_account") + permissionNames should contain("can_see_transaction_requests") + + // View Management permissions + permissionNames should contain("can_create_custom_view") + permissionNames should contain("can_delete_custom_view") + permissionNames should contain("can_update_custom_view") + permissionNames should contain("can_see_available_views_for_bank_account") + + // Access Control permissions + permissionNames should contain("can_grant_access_to_views") + permissionNames should contain("can_revoke_access_to_views") + permissionNames should contain("can_grant_access_to_custom_views") + permissionNames should contain("can_revoke_access_to_custom_views") + } + } +} \ No newline at end of file From 0db9ccacc17e223b1d35796fd275eba8b032d110 Mon Sep 17 00:00:00 2001 From: simonredfern Date: Mon, 15 Dec 2025 14:06:48 +0100 Subject: [PATCH 7/8] ABAC endpoints being served. --- .../main/scala/bootstrap/liftweb/Boot.scala | 2 + .../main/scala/code/abacrule/AbacRule.scala | 75 +++++++++++-------- 2 files changed, 46 insertions(+), 31 deletions(-) diff --git a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala index ce46e6922..ca41978cd 100644 --- a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala +++ b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala @@ -30,6 +30,7 @@ import code.CustomerDependants.MappedCustomerDependant import code.DynamicData.DynamicData import code.DynamicEndpoint.DynamicEndpoint import code.UserRefreshes.MappedUserRefreshes +import code.abacrule.MappedAbacRule import code.accountapplication.MappedAccountApplication import code.accountattribute.MappedAccountAttribute import code.accountholders.MapperAccountHolders @@ -1040,6 +1041,7 @@ object ToSchemify { MappedRegulatedEntity, AtmAttribute, Admin, + MappedAbacRule, MappedBank, MappedBankAccount, BankAccountRouting, diff --git a/obp-api/src/main/scala/code/abacrule/AbacRule.scala b/obp-api/src/main/scala/code/abacrule/AbacRule.scala index 493b60086..1f5a711b5 100644 --- a/obp-api/src/main/scala/code/abacrule/AbacRule.scala +++ b/obp-api/src/main/scala/code/abacrule/AbacRule.scala @@ -21,29 +21,42 @@ trait AbacRule { class MappedAbacRule extends AbacRule with LongKeyedMapper[MappedAbacRule] with IdPK with CreatedUpdated { def getSingleton = MappedAbacRule - object mAbacRuleId extends MappedString(this, 255) { + object AbacRuleId extends MappedString(this, 255) { override def defaultValue = APIUtil.generateUUID() + override def dbColumnName = "abac_rule_id" } - object mRuleName extends MappedString(this, 255) - object mRuleCode extends MappedText(this) - object mIsActive extends MappedBoolean(this) { + object RuleName extends MappedString(this, 255) { + override def dbColumnName = "rule_name" + } + object RuleCode extends MappedText(this) { + override def dbColumnName = "rule_code" + } + object IsActive extends MappedBoolean(this) { override def defaultValue = true + override def dbColumnName = "is_active" + } + object Description extends MappedText(this) { + override def dbColumnName = "description" + } + object CreatedByUserId extends MappedString(this, 255) { + override def dbColumnName = "created_by_user_id" + } + object UpdatedByUserId extends MappedString(this, 255) { + override def dbColumnName = "updated_by_user_id" } - object mDescription extends MappedText(this) - object mCreatedByUserId extends MappedString(this, 255) - object mUpdatedByUserId extends MappedString(this, 255) - override def abacRuleId: String = mAbacRuleId.get - override def ruleName: String = mRuleName.get - override def ruleCode: String = mRuleCode.get - override def isActive: Boolean = mIsActive.get - override def description: String = mDescription.get - override def createdByUserId: String = mCreatedByUserId.get - override def updatedByUserId: String = mUpdatedByUserId.get + override def abacRuleId: String = AbacRuleId.get + override def ruleName: String = RuleName.get + override def ruleCode: String = RuleCode.get + override def isActive: Boolean = IsActive.get + override def description: String = Description.get + override def createdByUserId: String = CreatedByUserId.get + override def updatedByUserId: String = UpdatedByUserId.get } object MappedAbacRule extends MappedAbacRule with LongKeyedMetaMapper[MappedAbacRule] { - override def dbIndexes: List[BaseIndex[MappedAbacRule]] = Index(mAbacRuleId) :: Index(mRuleName) :: Index(mCreatedByUserId) :: super.dbIndexes + override def dbTableName = "abac_rule" + override def dbIndexes: List[BaseIndex[MappedAbacRule]] = Index(AbacRuleId) :: Index(RuleName) :: Index(CreatedByUserId) :: super.dbIndexes } trait AbacRuleProvider { @@ -72,11 +85,11 @@ trait AbacRuleProvider { object MappedAbacRuleProvider extends AbacRuleProvider { override def getAbacRuleById(ruleId: String): Box[AbacRule] = { - MappedAbacRule.find(By(MappedAbacRule.mAbacRuleId, ruleId)) + MappedAbacRule.find(By(MappedAbacRule.AbacRuleId, ruleId)) } override def getAbacRuleByName(ruleName: String): Box[AbacRule] = { - MappedAbacRule.find(By(MappedAbacRule.mRuleName, ruleName)) + MappedAbacRule.find(By(MappedAbacRule.RuleName, ruleName)) } override def getAllAbacRules(): List[AbacRule] = { @@ -84,7 +97,7 @@ object MappedAbacRuleProvider extends AbacRuleProvider { } override def getActiveAbacRules(): List[AbacRule] = { - MappedAbacRule.findAll(By(MappedAbacRule.mIsActive, true)) + MappedAbacRule.findAll(By(MappedAbacRule.IsActive, true)) } override def createAbacRule( @@ -96,12 +109,12 @@ object MappedAbacRuleProvider extends AbacRuleProvider { ): Box[AbacRule] = { tryo { MappedAbacRule.create - .mRuleName(ruleName) - .mRuleCode(ruleCode) - .mDescription(description) - .mIsActive(isActive) - .mCreatedByUserId(createdBy) - .mUpdatedByUserId(createdBy) + .RuleName(ruleName) + .RuleCode(ruleCode) + .Description(description) + .IsActive(isActive) + .CreatedByUserId(createdBy) + .UpdatedByUserId(createdBy) .saveMe() } } @@ -115,14 +128,14 @@ object MappedAbacRuleProvider extends AbacRuleProvider { updatedBy: String ): Box[AbacRule] = { for { - rule <- MappedAbacRule.find(By(MappedAbacRule.mAbacRuleId, ruleId)) + rule <- MappedAbacRule.find(By(MappedAbacRule.AbacRuleId, ruleId)) updatedRule <- tryo { rule - .mRuleName(ruleName) - .mRuleCode(ruleCode) - .mDescription(description) - .mIsActive(isActive) - .mUpdatedByUserId(updatedBy) + .RuleName(ruleName) + .RuleCode(ruleCode) + .Description(description) + .IsActive(isActive) + .UpdatedByUserId(updatedBy) .saveMe() } } yield updatedRule @@ -130,7 +143,7 @@ object MappedAbacRuleProvider extends AbacRuleProvider { override def deleteAbacRule(ruleId: String): Box[Boolean] = { for { - rule <- MappedAbacRule.find(By(MappedAbacRule.mAbacRuleId, ruleId)) + rule <- MappedAbacRule.find(By(MappedAbacRule.AbacRuleId, ruleId)) deleted <- tryo(rule.delete_!) } yield deleted } From 7c7b0b153c5a1023dcd8a67a5bc9d9531b4b0626 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Mili=C4=87?= Date: Tue, 16 Dec 2025 09:51:21 +0100 Subject: [PATCH 8/8] =?UTF-8?q?docfix/Add=20Release=20Notes=20for=20Pekko?= =?UTF-8?q?=E2=84=A2=201.1.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- release_notes.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/release_notes.md b/release_notes.md index e12e9de0a..f33efd0d4 100644 --- a/release_notes.md +++ b/release_notes.md @@ -3,6 +3,27 @@ ### Most recent changes at top of file ``` Date Commit Action +11/12/2025 3c2df942 BREAKING CHANGE: Migration from Akka to Apache Pekko™ 1.1.2 + Replaced Akka 2.5.32 with Apache Pekko™ 1.1.2 to address Akka licensing changes. + Updated all imports from com.typesafe.akka to org.apache.pekko. + Updated Jetty from 9.4.50 to 9.4.58 for improved Java 17 compatibility. + + Migrated all actor systems to Apache Pekko™ and fixed critical scheduler + actor system initialization conflicts. + Consolidated all schedulers to use shared ObpActorSystem.localActorSystem. + Prevented multiple actor system creation during application boot. + + Fixed actor system references in all schedulers: + - DataBaseCleanerScheduler + - DatabaseDriverScheduler + - MetricsArchiveScheduler + - SchedulerUtil + - TransactionRequestStatusScheduler + + Resolved 'Address already in use' port binding errors. + Eliminated ExceptionInInitializerError during startup. + Fixed race conditions in actor system initialization. + All scheduler functionality preserved with improved stability. TBD TBD Performance Improvement: Added caching to getProviders endpoint Added configurable caching with memoization to GET /obp/v6.0.0/providers endpoint. - Default cache TTL: 3600 seconds (1 hour)