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
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
new file mode 100644
index 000000000..1f5a711b5
--- /dev/null
+++ b/obp-api/src/main/scala/code/abacrule/AbacRule.scala
@@ -0,0 +1,150 @@
+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 AbacRuleId extends MappedString(this, 255) {
+ override def defaultValue = APIUtil.generateUUID()
+ override def dbColumnName = "abac_rule_id"
+ }
+ 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"
+ }
+
+ 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 dbTableName = "abac_rule"
+ override def dbIndexes: List[BaseIndex[MappedAbacRule]] = Index(AbacRuleId) :: Index(RuleName) :: Index(CreatedByUserId) :: 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.AbacRuleId, ruleId))
+ }
+
+ override def getAbacRuleByName(ruleName: String): Box[AbacRule] = {
+ MappedAbacRule.find(By(MappedAbacRule.RuleName, ruleName))
+ }
+
+ override def getAllAbacRules(): List[AbacRule] = {
+ MappedAbacRule.findAll()
+ }
+
+ override def getActiveAbacRules(): List[AbacRule] = {
+ MappedAbacRule.findAll(By(MappedAbacRule.IsActive, true))
+ }
+
+ override def createAbacRule(
+ ruleName: String,
+ ruleCode: String,
+ description: String,
+ isActive: Boolean,
+ createdBy: String
+ ): Box[AbacRule] = {
+ tryo {
+ MappedAbacRule.create
+ .RuleName(ruleName)
+ .RuleCode(ruleCode)
+ .Description(description)
+ .IsActive(isActive)
+ .CreatedByUserId(createdBy)
+ .UpdatedByUserId(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.AbacRuleId, ruleId))
+ updatedRule <- tryo {
+ rule
+ .RuleName(ruleName)
+ .RuleCode(ruleCode)
+ .Description(description)
+ .IsActive(isActive)
+ .UpdatedByUserId(updatedBy)
+ .saveMe()
+ }
+ } yield updatedRule
+ }
+
+ override def deleteAbacRule(ruleId: String): Box[Boolean] = {
+ for {
+ rule <- MappedAbacRule.find(By(MappedAbacRule.AbacRuleId, 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/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..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,8 +26,10 @@ 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.{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
import code.bankconnectors.LocalMappedConnectorInternal
import code.bankconnectors.LocalMappedConnectorInternal._
@@ -74,12 +76,12 @@ trait APIMethods600 {
val Implementations6_0_0 = new Implementations600()
- class Implementations600 extends MdcLoggable {
+ class Implementations600 extends RestHelper with MdcLoggable {
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)
@@ -4138,6 +4140,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/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))
+ }
}
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
diff --git a/pom.xml b/pom.xml
index f179b8559..1da0298ab 100644
--- a/pom.xml
+++ b/pom.xml
@@ -16,7 +16,7 @@
1.8.2
3.5.0
0.23.30
- 9.4.58.v20250814
+ 9.4.50.v20250814
2016.11-RC6-SNAPSHOT
UTF-8
diff --git a/release_notes.md b/release_notes.md
index 2c315ea6c..f6b79cb2c 100644
--- a/release_notes.md
+++ b/release_notes.md
@@ -9,6 +9,27 @@ Date Commit Action
- http4s.port=8086
These properties control the bind address of bootstrap.http4s.Http4sServer
when running via the obp-http4s-runner fat JAR.
+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)