ABAC Policy instead of tag

This commit is contained in:
simonredfern 2026-01-14 09:32:23 +01:00
parent d95189e36f
commit f95e8b8645
5 changed files with 255 additions and 1 deletions

View File

@ -298,6 +298,70 @@ object AbacRuleEngine {
/**
* Execute all active ABAC rules with a specific policy (OR logic - at least one must pass)
* @param logic The logic to apply: "AND" (all must pass), "OR" (any must pass), "XOR" (exactly one must pass)
*
* @param policy The policy to filter rules by
* @param authenticatedUserId The ID of the authenticated user
* @param onBehalfOfUserId Optional ID of user being acted on behalf of
* @param userId The ID of the target user to evaluate
* @param callContext Call context for fetching objects
* @param bankId Optional bank ID
* @param accountId Optional account ID
* @param viewId Optional view ID
* @param transactionId Optional transaction ID
* @param transactionRequestId Optional transaction request ID
* @param customerId Optional customer ID
* @return Box[Boolean] - Full(true) if at least one rule passes (OR logic), Full(false) if all fail
*/
def executeRulesByPolicy(
policy: String,
authenticatedUserId: String,
onBehalfOfUserId: Option[String] = None,
userId: Option[String] = None,
callContext: CallContext,
bankId: Option[String] = None,
accountId: Option[String] = None,
viewId: Option[String] = None,
transactionId: Option[String] = None,
transactionRequestId: Option[String] = None,
customerId: Option[String] = None
): Box[Boolean] = {
val rules = MappedAbacRuleProvider.getActiveAbacRulesByPolicy(policy)
if (rules.isEmpty) {
// No rules for this policy - default to allow
Full(true)
} else {
// Execute all rules and check if at least one passes
val results = rules.map { rule =>
executeRule(
ruleId = rule.abacRuleId,
authenticatedUserId = authenticatedUserId,
onBehalfOfUserId = onBehalfOfUserId,
userId = userId,
callContext = callContext,
bankId = bankId,
accountId = accountId,
viewId = viewId,
transactionId = transactionId,
transactionRequestId = transactionRequestId,
customerId = customerId
)
}
// Count successes and failures
val successes = results.filter {
case Full(true) => true
case _ => false
}
// At least one rule must pass (OR logic)
Full(successes.nonEmpty)
}
}
/**
* Validate ABAC rule code by attempting to compile it
*

View File

@ -14,6 +14,7 @@ trait AbacRuleTrait {
def ruleCode: String
def isActive: Boolean
def description: String
def policy: String
def createdByUserId: String
def updatedByUserId: String
}
@ -30,6 +31,7 @@ class AbacRule extends AbacRuleTrait with LongKeyedMapper[AbacRule] with IdPK wi
override def defaultValue = true
}
object Description extends MappedText(this)
object Policy extends MappedText(this)
object CreatedByUserId extends MappedString(this, 255)
object UpdatedByUserId extends MappedString(this, 255)
@ -38,6 +40,7 @@ class AbacRule extends AbacRuleTrait with LongKeyedMapper[AbacRule] with IdPK wi
override def ruleCode: String = RuleCode.get
override def isActive: Boolean = IsActive.get
override def description: String = Description.get
override def policy: String = Policy.get
override def createdByUserId: String = CreatedByUserId.get
override def updatedByUserId: String = UpdatedByUserId.get
}
@ -51,10 +54,13 @@ trait AbacRuleProvider {
def getAbacRuleByName(ruleName: String): Box[AbacRuleTrait]
def getAllAbacRules(): List[AbacRuleTrait]
def getActiveAbacRules(): List[AbacRuleTrait]
def getAbacRulesByPolicy(policy: String): List[AbacRuleTrait]
def getActiveAbacRulesByPolicy(policy: String): List[AbacRuleTrait]
def createAbacRule(
ruleName: String,
ruleCode: String,
description: String,
policy: String,
isActive: Boolean,
createdBy: String
): Box[AbacRuleTrait]
@ -63,6 +69,7 @@ trait AbacRuleProvider {
ruleName: String,
ruleCode: String,
description: String,
policy: String,
isActive: Boolean,
updatedBy: String
): Box[AbacRuleTrait]
@ -87,10 +94,23 @@ object MappedAbacRuleProvider extends AbacRuleProvider {
AbacRule.findAll(By(AbacRule.IsActive, true))
}
override def getAbacRulesByPolicy(policy: String): List[AbacRuleTrait] = {
AbacRule.findAll().filter { rule =>
rule.policy.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)
}
}
override def createAbacRule(
ruleName: String,
ruleCode: String,
description: String,
policy: String,
isActive: Boolean,
createdBy: String
): Box[AbacRuleTrait] = {
@ -99,6 +119,7 @@ object MappedAbacRuleProvider extends AbacRuleProvider {
.RuleName(ruleName)
.RuleCode(ruleCode)
.Description(description)
.Policy(policy)
.IsActive(isActive)
.CreatedByUserId(createdBy)
.UpdatedByUserId(createdBy)
@ -111,6 +132,7 @@ object MappedAbacRuleProvider extends AbacRuleProvider {
ruleName: String,
ruleCode: String,
description: String,
policy: String,
isActive: Boolean,
updatedBy: String
): Box[AbacRuleTrait] = {
@ -121,6 +143,7 @@ object MappedAbacRuleProvider extends AbacRuleProvider {
.RuleName(ruleName)
.RuleCode(ruleCode)
.Description(description)
.Policy(policy)
.IsActive(isActive)
.UpdatedByUserId(updatedBy)
.saveMe()

View File

@ -266,6 +266,19 @@ object Constant extends MdcLoggable {
// ABAC Cache Prefixes (with global namespace and versioning)
def ABAC_RULE_PREFIX: String = getVersionedCachePrefix(ABAC_RULE_NAMESPACE)
// ABAC Policy Constants
final val ABAC_POLICY_ACCOUNT_ACCESS = "account-access"
// List of all ABAC Policies
final val ABAC_POLICIES: List[String] = List(
ABAC_POLICY_ACCOUNT_ACCESS
)
// Map of ABAC Policies to their descriptions
final val ABAC_POLICY_DESCRIPTIONS: Map[String, String] = Map(
ABAC_POLICY_ACCOUNT_ACCESS -> "Rules for controlling access to account information and account-related operations"
)
final val CAN_SEE_TRANSACTION_OTHER_BANK_ACCOUNT = "can_see_transaction_other_bank_account"
final val CAN_SEE_TRANSACTION_METADATA = "can_see_transaction_metadata"
final val CAN_SEE_TRANSACTION_DESCRIPTION = "can_see_transaction_description"

View File

@ -27,7 +27,7 @@ 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.{AddUserToGroupResponseJsonV600, DynamicEntityDiagnosticsJsonV600, DynamicEntityIssueJsonV600, GroupEntitlementJsonV600, GroupEntitlementsJsonV600, GroupJsonV600, GroupsJsonV600, PostGroupJsonV600, PostGroupMembershipJsonV600, PostResetPasswordUrlJsonV600, PutGroupJsonV600, ReferenceTypeJsonV600, ReferenceTypesJsonV600, ResetPasswordUrlJsonV600, RoleWithEntitlementCountJsonV600, RolesWithEntitlementCountsJsonV600, ScannedApiVersionJsonV600, UpdateViewJsonV600, UserGroupMembershipJsonV600, UserGroupMembershipsJsonV600, ValidateUserEmailJsonV600, ValidateUserEmailResponseJsonV600, ViewJsonV600, ViewPermissionJsonV600, ViewPermissionsJsonV600, ViewsJsonV600, createAbacRuleJsonV600, createAbacRulesJsonV600, createActiveRateLimitsJsonV600, createCallLimitJsonV600, createRedisCallCountersJson}
import code.api.v6_0_0.{AbacRuleJsonV600, AbacRuleResultJsonV600, AbacRulesJsonV600, CacheConfigJsonV600, CacheInfoJsonV600, CacheNamespaceInfoJsonV600, CreateAbacRuleJsonV600, CurrentConsumerJsonV600, ExecuteAbacRuleJsonV600, InMemoryCacheStatusJsonV600, RedisCacheStatusJsonV600, UpdateAbacRuleJsonV600}
import code.api.v6_0_0.{AbacRuleJsonV600, AbacRuleResultJsonV600, AbacRulesJsonV600, CacheConfigJsonV600, CacheInfoJsonV600, CacheNamespaceInfoJsonV600, CreateAbacRuleJsonV600, CurrentConsumerJsonV600, ExecuteAbacRuleJsonV600, InMemoryCacheStatusJsonV600, RedisCacheStatusJsonV600, UpdateAbacRuleJsonV600, AbacPoliciesJsonV600, AbacPolicyJsonV600}
import code.api.v6_0_0.OBPAPI6_0_0
import code.abacrule.{AbacRuleEngine, MappedAbacRuleProvider}
import code.metrics.APIMetrics
@ -4732,6 +4732,7 @@ trait APIMethods600 {
rule_name = "admin_only",
rule_code = """user.emailAddress.contains("admin")""",
description = "Only allow access to users with admin email",
policy = "user-access,admin",
is_active = true
),
AbacRuleJsonV600(
@ -4740,6 +4741,7 @@ trait APIMethods600 {
rule_code = """user.emailAddress.contains("admin")""",
is_active = true,
description = "Only allow access to users with admin email",
policy = "user-access,admin",
created_by_user_id = "user123",
updated_by_user_id = "user123"
),
@ -4779,6 +4781,7 @@ trait APIMethods600 {
ruleName = createJson.rule_name,
ruleCode = createJson.rule_code,
description = createJson.description,
policy = createJson.policy,
isActive = createJson.is_active,
createdBy = user.userId
)
@ -4815,6 +4818,7 @@ trait APIMethods600 {
rule_code = """user.emailAddress.contains("admin")""",
is_active = true,
description = "Only allow access to users with admin email",
policy = "user-access,admin",
created_by_user_id = "user123",
updated_by_user_id = "user123"
),
@ -4870,6 +4874,7 @@ trait APIMethods600 {
rule_code = """user.emailAddress.contains("admin")""",
is_active = true,
description = "Only allow access to users with admin email",
policy = "user-access,admin",
created_by_user_id = "user123",
updated_by_user_id = "user123"
)
@ -4899,6 +4904,75 @@ trait APIMethods600 {
}
}
staticResourceDocs += ResourceDoc(
getAbacRulesByPolicy,
implementedInApiVersion,
nameOf(getAbacRulesByPolicy),
"GET",
"/management/abac-rules/policy/POLICY",
"Get ABAC Rules by Policy",
s"""Get all ABAC rules that belong to a specific policy.
|
|Multiple rules can share the same policy. Rules with multiple policies (comma-separated)
|will be returned if any of their policies match the requested policy.
|
|**Documentation:**
|- ${Glossary.getGlossaryItemLink("ABAC_Simple_Guide")} - Getting started with ABAC rules
|- ${Glossary.getGlossaryItemLink("ABAC_Parameters_Summary")} - Complete list of all 18 parameters
|- ${Glossary.getGlossaryItemLink("ABAC_Object_Properties_Reference")} - Detailed property reference
|
|${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",
policy = "user-access,admin",
created_by_user_id = "user123",
updated_by_user_id = "user123"
),
AbacRuleJsonV600(
abac_rule_id = "def456",
rule_name = "admin_department_check",
rule_code = """user.department == "admin"""",
is_active = true,
description = "Check if user is in admin department",
policy = "admin",
created_by_user_id = "user123",
updated_by_user_id = "user123"
)
)
),
List(
UserNotLoggedIn,
UserHasMissingRoles,
UnknownError
),
List(apiTagABAC),
Some(List(canGetAbacRule))
)
lazy val getAbacRulesByPolicy: OBPEndpoint = {
case "management" :: "abac-rules" :: "policy" :: policy :: 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.getAbacRulesByPolicy(policy)
}
} yield {
(createAbacRulesJsonV600(rules), HttpCode.`200`(callContext))
}
}
}
staticResourceDocs += ResourceDoc(
updateAbacRule,
implementedInApiVersion,
@ -4920,6 +4994,7 @@ trait APIMethods600 {
rule_name = "admin_only_updated",
rule_code = """user.emailAddress.contains("admin") && user.provider == "obp"""",
description = "Only allow access to OBP admin users",
policy = "user-access,admin,obp",
is_active = true
),
AbacRuleJsonV600(
@ -4928,6 +5003,7 @@ trait APIMethods600 {
rule_code = """user.emailAddress.contains("admin") && user.provider == "obp"""",
is_active = true,
description = "Only allow access to OBP admin users",
policy = "user-access,admin,obp",
created_by_user_id = "user123",
updated_by_user_id = "user456"
),
@ -4962,6 +5038,7 @@ trait APIMethods600 {
ruleName = updateJson.rule_name,
ruleCode = updateJson.rule_code,
description = updateJson.description,
policy = updateJson.policy,
isActive = updateJson.is_active,
updatedBy = user.userId
)
@ -5079,11 +5156,13 @@ trait APIMethods600 {
rule_name = "Check User Identity",
rule_code = "authenticatedUser.userId == user.userId",
description = "Verify that the authenticated user matches the target user",
policy = "user-access",
is_active = true
),
AbacRuleExampleJsonV600(
rule_name = "Check Specific Bank",
rule_code = "bankOpt.isDefined && bankOpt.get.bankId.value == \"gh.29.uk\"",
policy = "bank-access",
description = "Verify that the bank context is defined and matches a specific bank ID",
is_active = true
)
@ -5247,48 +5326,56 @@ trait APIMethods600 {
rule_name = "Branch Manager Internal Account Access",
rule_code = "authenticatedUserEntitlements.exists(e => e.roleName == \"CanReadAccountsAtOneBank\") && authenticatedUserAttributes.exists(a => a.name == \"branch\" && accountAttributes.exists(aa => aa.name == \"branch\" && a.value == aa.value)) && callContext.exists(_.verb.exists(_ == \"GET\")) && accountOpt.exists(_.accountType == \"CURRENT\")",
description = "Allow GET access to current accounts when user has CanReadAccountsAtOneBank role and branch matches account's branch",
policy = "account-access",
is_active = true
),
AbacRuleExampleJsonV600(
rule_name = "Internal Network High-Value Transaction Review",
rule_code = "callContext.exists(_.ipAddress.exists(_.startsWith(\"10.\"))) && authenticatedUserEntitlements.exists(e => e.roleName == \"CanReadTransactionsAtOneBank\") && transactionOpt.exists(_.amount > 10000)",
description = "Allow users with CanReadTransactionsAtOneBank role on internal network to review high-value transactions over 10,000",
policy = "transaction-access",
is_active = true
),
AbacRuleExampleJsonV600(
rule_name = "Department Head Same-Department Account Read where overdrawn",
rule_code = "authenticatedUserEntitlements.exists(e => e.roleName == \"CanReadAccountsAtOneBank\") && authenticatedUserAttributes.exists(ua => ua.name == \"department\" && accountAttributes.exists(aa => aa.name == \"department\" && ua.value == aa.value)) && callContext.exists(_.url.exists(_.contains(\"/accounts/\"))) && accountOpt.exists(_.balance < 0)",
description = "Allow users with CanReadAccountsAtOneBank role to read overdrawn accounts in their department",
policy = "account-access",
is_active = true
),
AbacRuleExampleJsonV600(
rule_name = "Manager Internal Network Transaction Approval",
rule_code = "authenticatedUserEntitlements.exists(e => e.roleName == \"CanCreateTransactionRequest\") && callContext.exists(_.ipAddress.exists(ip => ip.startsWith(\"10.\") || ip.startsWith(\"192.168.\"))) && transactionRequestOpt.exists(tr => tr.status == \"PENDING\" && tr.charge.value.toDouble < 50000)",
description = "Allow users with CanCreateTransactionRequest role on internal network to approve pending transaction requests under 50,000",
policy = "transaction-request",
is_active = true
),
AbacRuleExampleJsonV600(
rule_name = "KYC Officer Customer Creation from Branch",
rule_code = "authenticatedUserEntitlements.exists(e => e.roleName == \"CanCreateCustomer\") && authenticatedUserAttributes.exists(a => a.name == \"certification\" && a.value == \"kyc_certified\") && callContext.exists(_.verb.exists(_ == \"POST\")) && callContext.exists(_.ipAddress.exists(_.startsWith(\"10.20.\"))) && customerAttributes.exists(ca => ca.name == \"onboarding_status\" && ca.value == \"pending\")",
description = "Allow users with CanCreateCustomer role and KYC certification to create customers via POST from branch network (10.20.x.x) when status is pending",
policy = "customer-access",
is_active = true
),
AbacRuleExampleJsonV600(
rule_name = "International Team Foreign Currency Transaction",
rule_code = "authenticatedUserEntitlements.exists(e => e.roleName == \"CanReadTransactionsAtOneBank\") && authenticatedUserAttributes.exists(a => a.name == \"team\" && a.value == \"international\") && callContext.exists(_.url.exists(_.contains(\"/transactions/\"))) && transactionOpt.exists(t => t.currency != \"USD\" && t.amount < 100000) && accountOpt.exists(a => accountAttributes.exists(aa => aa.name == \"international_enabled\" && aa.value == \"true\"))",
description = "Allow international team users with CanReadTransactionsAtOneBank role to access foreign currency transactions under 100k on international-enabled accounts",
policy = "transaction-access",
is_active = true
),
AbacRuleExampleJsonV600(
rule_name = "Assistant with Limited Delegation Account View",
rule_code = "onBehalfOfUserOpt.isDefined && onBehalfOfUserEntitlements.exists(e => e.roleName == \"CanReadAccountsAtOneBank\") && authenticatedUserAttributes.exists(a => a.name == \"assistant_of\" && onBehalfOfUserOpt.exists(u => a.value == u.userId)) && callContext.exists(_.verb.exists(_ == \"GET\")) && accountOpt.exists(a => accountAttributes.exists(aa => aa.name == \"tier\" && List(\"gold\", \"platinum\").contains(aa.value)))",
description = "Allow assistants to view gold/platinum accounts via GET when acting on behalf of a user with CanReadAccountsAtOneBank role",
policy = "account-access",
is_active = true
),
AbacRuleExampleJsonV600(
rule_name = "Fraud Analyst High-Risk Transaction Access",
rule_code = "authenticatedUserEntitlements.exists(e => e.roleName == \"CanReadTransactionsAtOneBank\") && callContext.exists(c => c.verb.exists(_ == \"GET\") && c.implementedByPartialFunction.exists(_.contains(\"Transaction\"))) && transactionAttributes.exists(ta => ta.name == \"risk_score\" && ta.value.toInt >= 75) && transactionOpt.exists(_.status.exists(_ != \"COMPLETED\"))",
description = "Allow users with CanReadTransactionsAtOneBank role to GET high-risk (score ≥75) non-completed transactions",
policy = "transaction-access",
is_active = true
)
),
@ -5315,6 +5402,59 @@ trait APIMethods600 {
}
}
staticResourceDocs += ResourceDoc(
getAbacPolicies,
implementedInApiVersion,
nameOf(getAbacPolicies),
"GET",
"/management/abac-policies",
"Get ABAC Policies",
s"""Get the list of allowed ABAC policy names.
|
|ABAC rules are organized by policies. Each rule must have at least one policy assigned.
|Rules can have multiple policies (comma-separated). This endpoint returns the list of
|standardized policy names that should be used when creating or updating rules.
|
|${userAuthenticationMessage(true)}
|
|""".stripMargin,
EmptyBody,
AbacPoliciesJsonV600(
policies = List(
AbacPolicyJsonV600(
policy = "account-access",
description = "Rules for controlling access to account information"
)
)
),
List(
UserNotLoggedIn,
UserHasMissingRoles,
UnknownError
),
List(apiTagABAC),
Some(List(canGetAbacRule))
)
lazy val getAbacPolicies: OBPEndpoint = {
case "management" :: "abac-policies" :: Nil JsonGet _ => {
cc => implicit val ec = EndpointContext(Some(cc))
for {
(Full(user), callContext) <- authenticatedAccess(cc)
_ <- NewStyle.function.hasEntitlement("", user.userId, canGetAbacRule, callContext)
} yield {
val policies = Constant.ABAC_POLICIES.map { policy =>
AbacPolicyJsonV600(
policy = policy,
description = Constant.ABAC_POLICY_DESCRIPTIONS.getOrElse(policy, "No description available")
)
}
(AbacPoliciesJsonV600(policies), HttpCode.`200`(callContext))
}
}
}
staticResourceDocs += ResourceDoc(
validateAbacRule,
implementedInApiVersion,

View File

@ -380,6 +380,7 @@ case class CreateAbacRuleJsonV600(
rule_name: String,
rule_code: String,
description: String,
policy: String,
is_active: Boolean
)
@ -387,6 +388,7 @@ case class UpdateAbacRuleJsonV600(
rule_name: String,
rule_code: String,
description: String,
policy: String,
is_active: Boolean
)
@ -396,6 +398,7 @@ case class AbacRuleJsonV600(
rule_code: String,
is_active: Boolean,
description: String,
policy: String,
created_by_user_id: String,
updated_by_user_id: String
)
@ -462,6 +465,7 @@ case class AbacRuleExampleJsonV600(
rule_name: String,
rule_code: String,
description: String,
policy: String,
is_active: Boolean
)
@ -473,6 +477,15 @@ case class AbacRuleSchemaJsonV600(
notes: List[String]
)
case class AbacPolicyJsonV600(
policy: String,
description: String
)
case class AbacPoliciesJsonV600(
policies: List[AbacPolicyJsonV600]
)
object JSONFactory600 extends CustomJsonFormats with MdcLoggable {
def createRedisCallCountersJson(
@ -1086,6 +1099,7 @@ object JSONFactory600 extends CustomJsonFormats with MdcLoggable {
rule_code = rule.ruleCode,
is_active = rule.isActive,
description = rule.description,
policy = rule.policy,
created_by_user_id = rule.createdByUserId,
updated_by_user_id = rule.updatedByUserId
)