Skip to content

Commit 83e4dae

Browse files
authored
Merge pull request #2717 from simonredfern/develop
Verify User Credentials tests and logic, Get Users with Account Access endpoint, ABAC rule too permissive, getPrivateAccountByIdFull v6.0.0
2 parents 9ee8664 + 4df70fc commit 83e4dae

32 files changed

+9799
-1182
lines changed

LIFT_HTTP4S_COEXISTENCE.md

Lines changed: 72 additions & 942 deletions
Large diffs are not rendered by default.

README.md

Lines changed: 17 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -211,9 +211,9 @@ db.driver=org.h2.Driver
211211
db.url=jdbc:h2:./obp_api.db;DB_CLOSE_ON_EXIT=FALSE
212212
```
213213

214-
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.
215-
Please use the following values:
216-
Note: make sure the JDBC URL used matches your Props value!
214+
**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.
215+
216+
Use the following connection values (make sure the JDBC URL matches your Props value):
217217

218218
```
219219
Driver Class: org.h2.Driver
@@ -388,16 +388,7 @@ To populate the OBP database with sandbox data:
388388

389389
## Production Options
390390

391-
- set the status of HttpOnly and Secure cookie flags for production, uncomment the following lines of `webapp/WEB-INF/web.xml`:
392-
393-
```XML
394-
<session-config>
395-
<cookie-config>
396-
<secure>true</secure>
397-
<http-only>true</http-only>
398-
</cookie-config>
399-
</session-config>
400-
```
391+
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.
401392

402393
## Server Mode Configuration (Removed)
403394

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

755746
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`
756747

757-
## Scala / Lift
748+
## Technology Stack
749+
750+
OBP-API uses the following core technologies:
751+
752+
- **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.
753+
- **Routing:** Priority-based routing defined in `Http4sApp.scala`:
754+
1. Native http4s routes for v5.0.0, v7.0.0, and Berlin Group v2
755+
2. A Lift bridge fallback (`Http4sLiftWebBridge`) for all other API versions
756+
- **ORM / Database:** [Lift Mapper](http://www.liftweb.net/) for database access and schema management.
757+
- **JSON:** Lift JSON utilities are used in some areas alongside native http4s JSON handling.
758758

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

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

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

766765
## Endpoint Request and Response Example
767766

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
syntax = "proto3";
2+
3+
package code.bankconnectors.grpc;
4+
5+
service ObpConnectorService {
6+
rpc ProcessObpRequest (ObpConnectorRequest) returns (ObpConnectorResponse) {}
7+
}
8+
9+
message ObpConnectorRequest {
10+
string method_name = 1; // e.g. "obp_get_banks"
11+
string json_payload = 2; // JSON-serialized OutBound DTO
12+
}
13+
14+
message ObpConnectorResponse {
15+
string json_payload = 1; // JSON-serialized InBound DTO
16+
}

obp-api/src/main/resources/props/sample.props.template

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,21 @@ hostname=http://127.0.0.1
294294
# Set this to your frontend/portal URL so that emails contain the correct link.
295295
portal_external_url=http://localhost:5174
296296

297+
# Public app URLs for the App Directory endpoint (GET /app-directory).
298+
# Any props starting with public_ and ending with _url are returned by that endpoint.
299+
# Defaults are localhost development ports. Override for production deployments.
300+
# Set to empty string to indicate to calling applications they should not display.
301+
public_obp_api_url=http://localhost:8080
302+
public_obp_portal_url=http://localhost:5174
303+
public_obp_api_explorer_url=http://localhost:5173
304+
public_obp_api_manager_url=http://localhost:3003
305+
public_obp_sandbox_populator_url=http://localhost:5178
306+
public_obp_oidc_url=http://localhost:9000
307+
public_keycloak_url=http://localhost:7787
308+
public_obp_hola_url=http://localhost:8087
309+
public_obp_mcp_url=http://localhost:9100
310+
public_obp_opey_url=http://localhost:5000
311+
297312
## This port is used for local development
298313
## Note: OBP-API now uses http4s server
299314
## To start the server, use: java -jar obp-api/target/obp-api.jar
@@ -851,6 +866,11 @@ password_reset_token_expiry_minutes=120
851866
# control the create and access to public views.
852867
# allow_public_views=false
853868

869+
# Enable ABAC (Attribute-Based Access Control) for account access.
870+
# When true, users with CanExecuteAbacRule entitlement can gain access
871+
# to accounts via ABAC rules with the "account-access" policy.
872+
# allow_abac_account_access=false
873+
854874
# control access to account firehose.
855875
# allow_account_firehose=false
856876
# control access to customer firehose.
@@ -1733,4 +1753,4 @@ securelogging_mask_email=true
17331753
# Signal Channels (Redis-backed ephemeral channels for AI agent coordination)
17341754
############################################
17351755
# messaging.channel.ttl.seconds=3600
1736-
# messaging.channel.max.messages=1000
1756+
# messaging.channel.max.messages=1000

obp-api/src/main/scala/code/abacrule/AbacRuleEngine.scala

Lines changed: 193 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
package code.abacrule
22

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

27+
val StatisticalSampleSize: Int = 20
28+
val PermissivenessThreshold: Double = 0.50
29+
2730
/**
2831
* Type alias for compiled ABAC rule function
2932
* Parameters: authenticatedUser (logged in), authenticatedUserAttributes (non-personal), authenticatedUserAuthContext (auth context), authenticatedUserEntitlements (roles),
@@ -83,6 +86,106 @@ object AbacRuleEngine {
8386
|""".stripMargin
8487
}
8588

89+
/**
90+
* Check if a rule code is too permissive (contains tautological expressions that always evaluate to true).
91+
*
92+
* Detects two categories:
93+
* 1. Whole-body tautologies: the entire rule is a trivially-true expression (e.g. "true", "1==1")
94+
* 2. Sub-expression tautologies: a tautological operand after || makes the whole expression always true
95+
*
96+
* Note: "&& true" is NOT flagged — it's redundant but doesn't increase permissiveness.
97+
*
98+
* @param ruleCode The rule code to check
99+
* @return true if the rule code is too permissive
100+
*/
101+
private def isTooPermissive(ruleCode: String): Boolean = {
102+
val stripped = ruleCode.trim
103+
104+
// Whole-body tautology patterns (entire rule is trivially true)
105+
val wholeBodyTautologies = List(
106+
"""^true$""", // bare true
107+
"""^(\d+)\s*==\s*\1$""", // numeric identity: 1==1, 42==42
108+
""""([^"]+)"\s*==\s*"\1"""", // string identity: "a"=="a", "foo"=="foo"
109+
"""^true\s*==\s*true$""", // boolean identity: true==true
110+
"""^!false$""", // negated false
111+
"""^!\(false\)$""" // negated false with parens
112+
).map(_.r)
113+
114+
// Sub-expression tautology patterns (tautology after || anywhere in the rule)
115+
val subExprTautologies = List(
116+
"""\|\|\s*true(?!\s*==)""", // || true (but not || true==true, handled separately)
117+
"""\|\|\s*true\s*==\s*true""", // || true==true
118+
"""\|\|\s*(\d+)\s*==\s*\1""", // || 1==1, || 42==42
119+
"""\|\|\s*"([^"]+)"\s*==\s*"\1"""", // || "a"=="a"
120+
"""\|\|\s*!false""", // || !false
121+
"""\|\|\s*!\(false\)""" // || !(false)
122+
).map(_.r)
123+
124+
val isWholeBodyTautology = wholeBodyTautologies.exists(_.findFirstIn(stripped).isDefined)
125+
val hasSubExprTautology = subExprTautologies.exists(_.findFirstIn(stripped).isDefined)
126+
127+
isWholeBodyTautology || hasSubExprTautology
128+
}
129+
130+
/**
131+
* Statistical permissiveness check: compile the candidate rule, evaluate it against a sample
132+
* of real system users (with no resource context), and reject it if over 50% of users pass.
133+
*
134+
* @param ruleCode The rule code to check
135+
* @return Future[Boolean] - true if the rule is statistically too permissive
136+
*/
137+
private def isStatisticallyTooPermissive(ruleCode: String): Future[Boolean] = {
138+
val compiledBox = compileRuleInternal(ruleCode)
139+
compiledBox match {
140+
case Failure(_, _, _) | Empty =>
141+
// Compilation error caught elsewhere
142+
Future.successful(false)
143+
case Full(compiledFunc) =>
144+
val ns = code.api.util.NewStyle.function
145+
Users.users.vend.getAllUsersF(List(OBPLimit(StatisticalSampleSize))).flatMap { userEntitlementPairs =>
146+
if (userEntitlementPairs.isEmpty) {
147+
Future.successful(false)
148+
} else {
149+
val evaluationFutures = userEntitlementPairs.map { case (resourceUser, entitlementsBox) =>
150+
val userId = resourceUser.userId
151+
val entitlements = entitlementsBox.openOr(Nil)
152+
val userAsUser: User = resourceUser
153+
154+
val attributesF = ns.getNonPersonalUserAttributes(userId, None).map(_._1).recover { case _ => Nil }
155+
val authContextF = ns.getUserAuthContexts(userId, None).map(_._1).recover { case _ => Nil }
156+
157+
for {
158+
attributes <- attributesF
159+
authContext <- authContextF
160+
} yield {
161+
try {
162+
compiledFunc(
163+
userAsUser, attributes, authContext, entitlements,
164+
None, Nil, Nil, Nil, // onBehalfOfUser
165+
None, Nil, // user
166+
None, Nil, // bank
167+
None, Nil, // account
168+
None, Nil, // transaction
169+
None, Nil, // transactionRequest
170+
None, Nil, // customer
171+
None // callContext
172+
)
173+
} catch {
174+
case _: Exception => false
175+
}
176+
}
177+
}
178+
179+
Future.sequence(evaluationFutures).map { results =>
180+
val passCount = results.count(_ == true)
181+
val total = results.size
182+
passCount.toDouble / total > PermissivenessThreshold
183+
}
184+
}
185+
}
186+
}
187+
}
188+
86189
/**
87190
* Helper to lift a Box value into a Future, converting Failure/Empty to a failed Future.
88191
*/
@@ -322,8 +425,8 @@ object AbacRuleEngine {
322425
val rules = MappedAbacRuleProvider.getActiveAbacRulesByPolicy(policy)
323426

324427
if (rules.isEmpty) {
325-
// No rules for this policy - default to allow
326-
Future.successful(Full(true))
428+
// No rules for this policy - default to deny
429+
Future.successful(Full(false))
327430
} else {
328431
// Execute all rules in parallel and check if at least one passes
329432
val resultFutures = rules.map { rule =>
@@ -355,17 +458,100 @@ object AbacRuleEngine {
355458
}
356459
}
357460

461+
/**
462+
* Execute all active ABAC rules with a specific policy and return detailed results.
463+
* Returns which rule IDs denied access (for error reporting).
464+
*
465+
* @return Future[Box[(Boolean, List[String])]] - Full((true, Nil)) if any rule passes,
466+
* Full((false, failingRuleIds)) if all fail, Full((false, Nil)) if no rules exist
467+
*/
468+
def executeRulesByPolicyDetailed(
469+
policy: String,
470+
authenticatedUserId: String,
471+
callContext: CallContext,
472+
bankId: Option[String] = None,
473+
accountId: Option[String] = None,
474+
viewId: Option[String] = None
475+
): Future[Box[(Boolean, List[String])]] = {
476+
val rules = MappedAbacRuleProvider.getActiveAbacRulesByPolicy(policy)
477+
478+
if (rules.isEmpty) {
479+
// No rules for this policy - default to deny
480+
Future.successful(Full((false, Nil)))
481+
} else {
482+
// Execute all rules in parallel and collect results with rule IDs
483+
val resultFutures = rules.map { rule =>
484+
executeRule(
485+
ruleId = rule.abacRuleId,
486+
authenticatedUserId = authenticatedUserId,
487+
callContext = callContext,
488+
bankId = bankId,
489+
accountId = accountId,
490+
viewId = viewId
491+
).map(result => (rule.abacRuleId, result))
492+
}
493+
494+
Future.sequence(resultFutures).map { results =>
495+
val passed = results.exists {
496+
case (_, Full(true)) => true
497+
case _ => false
498+
}
499+
500+
if (passed) {
501+
Full((true, Nil))
502+
} else {
503+
val failingRuleIds = results.collect {
504+
case (ruleId, Full(false)) => ruleId
505+
case (ruleId, Failure(_, _, _)) => ruleId
506+
case (ruleId, Empty) => ruleId
507+
}
508+
Full((false, failingRuleIds.toList))
509+
}
510+
}
511+
}
512+
}
513+
358514
/**
359515
* Validate ABAC rule code by attempting to compile it
360516
*
361517
* @param ruleCode The Scala code to validate
362518
* @return Box[String] - Full("OK") if valid, Failure with error message if invalid
363519
*/
364520
def validateRuleCode(ruleCode: String): Box[String] = {
365-
compileRuleInternal(ruleCode) match {
366-
case Full(_) => Full("ABAC rule code is valid")
367-
case Failure(msg, _, _) => Failure(s"Invalid ABAC rule code: $msg")
368-
case Empty => Failure("Failed to validate ABAC rule code")
521+
if (isTooPermissive(ruleCode)) {
522+
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.")
523+
} else {
524+
compileRuleInternal(ruleCode) match {
525+
case Full(_) => Full("ABAC rule code is valid")
526+
case Failure(msg, _, _) => Failure(s"Invalid ABAC rule code: $msg")
527+
case Empty => Failure("Failed to validate ABAC rule code")
528+
}
529+
}
530+
}
531+
532+
/**
533+
* Async validation that includes both sync checks (regex + compilation) and
534+
* the statistical permissiveness check against real system users.
535+
*
536+
* @param ruleCode The Scala code to validate
537+
* @return Future[Box[String]] - Full("ABAC rule code is valid") if valid, Failure with error message if invalid
538+
*/
539+
def validateRuleCodeAsync(ruleCode: String): Future[Box[String]] = {
540+
val syncResult = validateRuleCode(ruleCode)
541+
syncResult match {
542+
case f @ Failure(_, _, _) =>
543+
Future.successful(f)
544+
case Full(_) =>
545+
isStatisticallyTooPermissive(ruleCode).map { tooPermissive =>
546+
if (tooPermissive)
547+
Failure(ErrorMessages.AbacRuleStatisticallyTooPermissive)
548+
else
549+
Full("ABAC rule code is valid")
550+
}.recover {
551+
case _ => Failure(ErrorMessages.AbacRuleStatisticallyTooPermissive)
552+
}
553+
case Empty =>
554+
Future.successful(Empty)
369555
}
370556
}
371557

obp-api/src/main/scala/code/abacrule/AbacRuleExamples.scala

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -283,10 +283,11 @@ object AbacRuleExamples {
283283
|accountOpt.exists(_.accountRoutings.nonEmpty)""".stripMargin
284284

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

291292
/**
292293
* Example 34: Default to False (Deny All)

obp-api/src/main/scala/code/abacrule/AbacRuleTrait.scala

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,13 +96,13 @@ object MappedAbacRuleProvider extends AbacRuleProvider {
9696

9797
override def getAbacRulesByPolicy(policy: String): List[AbacRuleTrait] = {
9898
AbacRule.findAll().filter { rule =>
99-
rule.policy.split(",").map(_.trim).contains(policy)
99+
Option(rule.policy).exists(_.split(",").map(_.trim).contains(policy))
100100
}
101101
}
102102

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

0 commit comments

Comments
 (0)