Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
f0bcde9
Merge remote-tracking branch 'upstream/develop' into develop
simonredfern Feb 23, 2026
607bdf2
Merge remote-tracking branch 'upstream/develop' into develop
simonredfern Feb 26, 2026
498c0de
Entitlement Request pagination + Enabling ABAC for account access
simonredfern Feb 26, 2026
8d7025a
Adding AbacRuleStatisticallyTooPermissive checks
simonredfern Feb 26, 2026
982d997
Merge remote-tracking branch 'upstream/develop' into develop
simonredfern Feb 26, 2026
512b4d7
Merge remote-tracking branch 'upstream/develop' into develop
simonredfern Feb 26, 2026
632d7ae
grpc_vFeb2026 connector builder and connector
simonredfern Feb 27, 2026
10f1203
grpc connector regenerated
simonredfern Feb 27, 2026
b3386b8
Merge remote-tracking branch 'upstream/develop' into develop
simonredfern Feb 27, 2026
e86ed67
Adding Get Users With Account Access endpoint
simonredfern Feb 28, 2026
cf43f7b
Updating README regarding use of http4s and liftweb
simonredfern Feb 28, 2026
34c6289
Users with Account Access
simonredfern Feb 28, 2026
c981411
remoteJWKSetCache so we don't have to call OIDC provider all the time.
simonredfern Feb 28, 2026
64b83ce
App Directory test, search Users by Role
simonredfern Mar 1, 2026
41c3004
logfix in getUsersWithAccountAccess
simonredfern Mar 2, 2026
07a509e
getUsersWithAccountAccess operates on view level
simonredfern Mar 2, 2026
3290c1a
rule.policy exists check
simonredfern Mar 2, 2026
a815177
getPrivateAccountByIdFull v6.0.0 list of allowed permissions rather than
simonredfern Mar 2, 2026
e35a47e
Features endpoint
simonredfern Mar 2, 2026
ebbf0c6
Merge remote-tracking branch 'upstream/develop' into develop
simonredfern Mar 3, 2026
8e70c4b
If , else instead of .or so we can better log paths. Don't increment bad
simonredfern Mar 3, 2026
c962ee1
Create VerifyExternalUserCredentialsTest.scala
simonredfern Mar 3, 2026
0747b00
Additional tests around login
simonredfern Mar 3, 2026
b712ec9
Whitelist the word keycloak
simonredfern Mar 3, 2026
4df70fc
Better calling of externalUserHelper
simonredfern Mar 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,014 changes: 72 additions & 942 deletions LIFT_HTTP4S_COEXISTENCE.md

Large diffs are not rendered by default.

35 changes: 17 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,9 +211,9 @@ db.driver=org.h2.Driver
db.url=jdbc:h2:./obp_api.db;DB_CLOSE_ON_EXIT=FALSE
```

In order to start H2 web console go to [http://127.0.0.1:8080/console](http://127.0.0.1:8080/console) and you will see a login screen.
Please use the following values:
Note: make sure the JDBC URL used matches your Props value!
**Note:** The H2 web console at `/console` was available when OBP-API ran on Jetty but is no longer served by the http4s server. To inspect the H2 database, connect directly using the [H2 Shell](https://h2database.com/html/tutorial.html#console_settings) or a database tool such as DBeaver.

Use the following connection values (make sure the JDBC URL matches your Props value):

```
Driver Class: org.h2.Driver
Expand Down Expand Up @@ -388,16 +388,7 @@ To populate the OBP database with sandbox data:

## Production Options

- set the status of HttpOnly and Secure cookie flags for production, uncomment the following lines of `webapp/WEB-INF/web.xml`:

```XML
<session-config>
<cookie-config>
<secure>true</secure>
<http-only>true</http-only>
</cookie-config>
</session-config>
```
OBP-API runs on http4s Ember. Standard security headers (Cache-Control, X-Frame-Options, Correlation-Id, etc.) are applied automatically by `Http4sLiftWebBridge.withStandardHeaders` to all responses. Cookie flags and other session-related settings can be configured via the props file.

## Server Mode Configuration (Removed)

Expand Down Expand Up @@ -754,14 +745,22 @@ There is a video about the detail: [demonstrate the detail of the feature](https

The same as `Frozen APIs`, if a related unit test fails, make sure whether the modification is required, if yes, run frozen util to re-generate frozen types metadata file. take `RestConnector_vMar2019` as an example, the corresponding util is `RestConnector_vMar2019_FrozenUtil`, the corresponding unit test is `RestConnector_vMar2019_FrozenTest`

## Scala / Lift
## Technology Stack

OBP-API uses the following core technologies:

- **HTTP Server:** [http4s](https://http4s.org/) with [Cats Effect](https://typelevel.org/cats-effect/) (`IOApp`). The server runs on http4s Ember in a single process on a single port.
- **Routing:** Priority-based routing defined in `Http4sApp.scala`:
1. Native http4s routes for v5.0.0, v7.0.0, and Berlin Group v2
2. A Lift bridge fallback (`Http4sLiftWebBridge`) for all other API versions
- **ORM / Database:** [Lift Mapper](http://www.liftweb.net/) for database access and schema management.
- **JSON:** Lift JSON utilities are used in some areas alongside native http4s JSON handling.

- We use scala and liftweb: [http://www.liftweb.net/](http://www.liftweb.net/).
For details on how the http4s and Lift layers coexist, see [LIFT_HTTP4S_COEXISTENCE.md](LIFT_HTTP4S_COEXISTENCE.md).

- Advanced architecture: [http://exploring.liftweb.net/master/index-9.html
](http://exploring.liftweb.net/master/index-9.html).
Liftweb architecture: [http://exploring.liftweb.net/master/index-9.html](http://exploring.liftweb.net/master/index-9.html).

- A good book on Lift: "Lift in Action" by Timothy Perrett published by Manning.
A good book on Lift: "Lift in Action" by Timothy Perrett published by Manning.

## Endpoint Request and Response Example

Expand Down
16 changes: 16 additions & 0 deletions obp-api/src/main/protobuf/connector.proto
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
syntax = "proto3";

package code.bankconnectors.grpc;

service ObpConnectorService {
rpc ProcessObpRequest (ObpConnectorRequest) returns (ObpConnectorResponse) {}
}

message ObpConnectorRequest {
string method_name = 1; // e.g. "obp_get_banks"
string json_payload = 2; // JSON-serialized OutBound DTO
}

message ObpConnectorResponse {
string json_payload = 1; // JSON-serialized InBound DTO
}
22 changes: 21 additions & 1 deletion obp-api/src/main/resources/props/sample.props.template
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,21 @@ hostname=http://127.0.0.1
# Set this to your frontend/portal URL so that emails contain the correct link.
portal_external_url=http://localhost:5174

# Public app URLs for the App Directory endpoint (GET /app-directory).
# Any props starting with public_ and ending with _url are returned by that endpoint.
# Defaults are localhost development ports. Override for production deployments.
# Set to empty string to indicate to calling applications they should not display.
public_obp_api_url=http://localhost:8080
public_obp_portal_url=http://localhost:5174
public_obp_api_explorer_url=http://localhost:5173
public_obp_api_manager_url=http://localhost:3003
public_obp_sandbox_populator_url=http://localhost:5178
public_obp_oidc_url=http://localhost:9000
public_keycloak_url=http://localhost:7787
public_obp_hola_url=http://localhost:8087
public_obp_mcp_url=http://localhost:9100
public_obp_opey_url=http://localhost:5000

## This port is used for local development
## Note: OBP-API now uses http4s server
## To start the server, use: java -jar obp-api/target/obp-api.jar
Expand Down Expand Up @@ -851,6 +866,11 @@ password_reset_token_expiry_minutes=120
# control the create and access to public views.
# allow_public_views=false

# Enable ABAC (Attribute-Based Access Control) for account access.
# When true, users with CanExecuteAbacRule entitlement can gain access
# to accounts via ABAC rules with the "account-access" policy.
# allow_abac_account_access=false

# control access to account firehose.
# allow_account_firehose=false
# control access to customer firehose.
Expand Down Expand Up @@ -1733,4 +1753,4 @@ securelogging_mask_email=true
# Signal Channels (Redis-backed ephemeral channels for AI agent coordination)
############################################
# messaging.channel.ttl.seconds=3600
# messaging.channel.max.messages=1000
# messaging.channel.max.messages=1000
200 changes: 193 additions & 7 deletions obp-api/src/main/scala/code/abacrule/AbacRuleEngine.scala
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package code.abacrule

import code.api.util.{APIUtil, CallContext, DynamicUtil}
import code.api.util.{APIUtil, CallContext, DynamicUtil, ErrorMessages, OBPQueryParam, OBPLimit}
import code.bankconnectors.Connector
import code.model.dataAccess.ResourceUser
import code.users.Users
Expand All @@ -24,6 +24,9 @@ object AbacRuleEngine {
private val compiledRulesCache: concurrent.Map[String, Box[AbacRuleFunction]] =
new ConcurrentHashMap[String, Box[AbacRuleFunction]]().asScala

val StatisticalSampleSize: Int = 20
val PermissivenessThreshold: Double = 0.50

/**
* Type alias for compiled ABAC rule function
* Parameters: authenticatedUser (logged in), authenticatedUserAttributes (non-personal), authenticatedUserAuthContext (auth context), authenticatedUserEntitlements (roles),
Expand Down Expand Up @@ -83,6 +86,106 @@ object AbacRuleEngine {
|""".stripMargin
}

/**
* Check if a rule code is too permissive (contains tautological expressions that always evaluate to true).
*
* Detects two categories:
* 1. Whole-body tautologies: the entire rule is a trivially-true expression (e.g. "true", "1==1")
* 2. Sub-expression tautologies: a tautological operand after || makes the whole expression always true
*
* Note: "&& true" is NOT flagged — it's redundant but doesn't increase permissiveness.
*
* @param ruleCode The rule code to check
* @return true if the rule code is too permissive
*/
private def isTooPermissive(ruleCode: String): Boolean = {
val stripped = ruleCode.trim

// Whole-body tautology patterns (entire rule is trivially true)
val wholeBodyTautologies = List(
"""^true$""", // bare true
"""^(\d+)\s*==\s*\1$""", // numeric identity: 1==1, 42==42
""""([^"]+)"\s*==\s*"\1"""", // string identity: "a"=="a", "foo"=="foo"
"""^true\s*==\s*true$""", // boolean identity: true==true
"""^!false$""", // negated false
"""^!\(false\)$""" // negated false with parens
).map(_.r)

// Sub-expression tautology patterns (tautology after || anywhere in the rule)
val subExprTautologies = List(
"""\|\|\s*true(?!\s*==)""", // || true (but not || true==true, handled separately)
"""\|\|\s*true\s*==\s*true""", // || true==true
"""\|\|\s*(\d+)\s*==\s*\1""", // || 1==1, || 42==42
"""\|\|\s*"([^"]+)"\s*==\s*"\1"""", // || "a"=="a"
"""\|\|\s*!false""", // || !false
"""\|\|\s*!\(false\)""" // || !(false)
).map(_.r)

val isWholeBodyTautology = wholeBodyTautologies.exists(_.findFirstIn(stripped).isDefined)
val hasSubExprTautology = subExprTautologies.exists(_.findFirstIn(stripped).isDefined)

isWholeBodyTautology || hasSubExprTautology
}

/**
* Statistical permissiveness check: compile the candidate rule, evaluate it against a sample
* of real system users (with no resource context), and reject it if over 50% of users pass.
*
* @param ruleCode The rule code to check
* @return Future[Boolean] - true if the rule is statistically too permissive
*/
private def isStatisticallyTooPermissive(ruleCode: String): Future[Boolean] = {
val compiledBox = compileRuleInternal(ruleCode)
compiledBox match {
case Failure(_, _, _) | Empty =>
// Compilation error caught elsewhere
Future.successful(false)
case Full(compiledFunc) =>
val ns = code.api.util.NewStyle.function
Users.users.vend.getAllUsersF(List(OBPLimit(StatisticalSampleSize))).flatMap { userEntitlementPairs =>
if (userEntitlementPairs.isEmpty) {
Future.successful(false)
} else {
val evaluationFutures = userEntitlementPairs.map { case (resourceUser, entitlementsBox) =>
val userId = resourceUser.userId
val entitlements = entitlementsBox.openOr(Nil)
val userAsUser: User = resourceUser

val attributesF = ns.getNonPersonalUserAttributes(userId, None).map(_._1).recover { case _ => Nil }
val authContextF = ns.getUserAuthContexts(userId, None).map(_._1).recover { case _ => Nil }

for {
attributes <- attributesF
authContext <- authContextF
} yield {
try {
compiledFunc(
userAsUser, attributes, authContext, entitlements,
None, Nil, Nil, Nil, // onBehalfOfUser
None, Nil, // user
None, Nil, // bank
None, Nil, // account
None, Nil, // transaction
None, Nil, // transactionRequest
None, Nil, // customer
None // callContext
)
} catch {
case _: Exception => false
}
}
}

Future.sequence(evaluationFutures).map { results =>
val passCount = results.count(_ == true)
val total = results.size
passCount.toDouble / total > PermissivenessThreshold
}
}
}
}
}

/**
* Helper to lift a Box value into a Future, converting Failure/Empty to a failed Future.
*/
Expand Down Expand Up @@ -322,8 +425,8 @@ object AbacRuleEngine {
val rules = MappedAbacRuleProvider.getActiveAbacRulesByPolicy(policy)

if (rules.isEmpty) {
// No rules for this policy - default to allow
Future.successful(Full(true))
// No rules for this policy - default to deny
Future.successful(Full(false))
} else {
// Execute all rules in parallel and check if at least one passes
val resultFutures = rules.map { rule =>
Expand Down Expand Up @@ -355,17 +458,100 @@ object AbacRuleEngine {
}
}

/**
* Execute all active ABAC rules with a specific policy and return detailed results.
* Returns which rule IDs denied access (for error reporting).
*
* @return Future[Box[(Boolean, List[String])]] - Full((true, Nil)) if any rule passes,
* Full((false, failingRuleIds)) if all fail, Full((false, Nil)) if no rules exist
*/
def executeRulesByPolicyDetailed(
policy: String,
authenticatedUserId: String,
callContext: CallContext,
bankId: Option[String] = None,
accountId: Option[String] = None,
viewId: Option[String] = None
): Future[Box[(Boolean, List[String])]] = {
val rules = MappedAbacRuleProvider.getActiveAbacRulesByPolicy(policy)

if (rules.isEmpty) {
// No rules for this policy - default to deny
Future.successful(Full((false, Nil)))
} else {
// Execute all rules in parallel and collect results with rule IDs
val resultFutures = rules.map { rule =>
executeRule(
ruleId = rule.abacRuleId,
authenticatedUserId = authenticatedUserId,
callContext = callContext,
bankId = bankId,
accountId = accountId,
viewId = viewId
).map(result => (rule.abacRuleId, result))
}

Future.sequence(resultFutures).map { results =>
val passed = results.exists {
case (_, Full(true)) => true
case _ => false
}

if (passed) {
Full((true, Nil))
} else {
val failingRuleIds = results.collect {
case (ruleId, Full(false)) => ruleId
case (ruleId, Failure(_, _, _)) => ruleId
case (ruleId, Empty) => ruleId
}
Full((false, failingRuleIds.toList))
}
}
}
}

/**
* 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")
if (isTooPermissive(ruleCode)) {
Failure("ABAC rule is too permissive: the rule code contains a tautological expression that would always grant access. Please write a rule that checks specific attributes.")
} else {
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")
}
}
}

/**
* Async validation that includes both sync checks (regex + compilation) and
* the statistical permissiveness check against real system users.
*
* @param ruleCode The Scala code to validate
* @return Future[Box[String]] - Full("ABAC rule code is valid") if valid, Failure with error message if invalid
*/
def validateRuleCodeAsync(ruleCode: String): Future[Box[String]] = {
val syncResult = validateRuleCode(ruleCode)
syncResult match {
case f @ Failure(_, _, _) =>
Future.successful(f)
case Full(_) =>
isStatisticallyTooPermissive(ruleCode).map { tooPermissive =>
if (tooPermissive)
Failure(ErrorMessages.AbacRuleStatisticallyTooPermissive)
else
Full("ABAC rule code is valid")
}.recover {
case _ => Failure(ErrorMessages.AbacRuleStatisticallyTooPermissive)
}
case Empty =>
Future.successful(Empty)
}
}

Expand Down
7 changes: 4 additions & 3 deletions obp-api/src/main/scala/code/abacrule/AbacRuleExamples.scala
Original file line number Diff line number Diff line change
Expand Up @@ -283,10 +283,11 @@ object AbacRuleExamples {
|accountOpt.exists(_.accountRoutings.nonEmpty)""".stripMargin

/**
* Example 33: Default to True (Allow All)
* Simple rule that always grants access (useful for testing)
* Example 33: Entitlement-Based Access
* Grants access to users who have the CanCreateAbacRule entitlement.
* This checks for a specific entitlement rather than always granting access.
*/
val allowAllRule: String = """true"""
val allowAllRule: String = """authenticatedUserEntitlements.exists(_.roleName == "CanCreateAbacRule")"""

/**
* Example 34: Default to False (Deny All)
Expand Down
4 changes: 2 additions & 2 deletions obp-api/src/main/scala/code/abacrule/AbacRuleTrait.scala
Original file line number Diff line number Diff line change
Expand Up @@ -96,13 +96,13 @@ object MappedAbacRuleProvider extends AbacRuleProvider {

override def getAbacRulesByPolicy(policy: String): List[AbacRuleTrait] = {
AbacRule.findAll().filter { rule =>
rule.policy.split(",").map(_.trim).contains(policy)
Option(rule.policy).exists(_.split(",").map(_.trim).contains(policy))
}
}

override def getActiveAbacRulesByPolicy(policy: String): List[AbacRuleTrait] = {
AbacRule.findAll(By(AbacRule.IsActive, true)).filter { rule =>
rule.policy.split(",").map(_.trim).contains(policy)
Option(rule.policy).exists(_.split(",").map(_.trim).contains(policy))
}
}

Expand Down
Loading