diff --git a/obp-api/src/main/scala/code/abacrule/AbacRuleEngine.scala b/obp-api/src/main/scala/code/abacrule/AbacRuleEngine.scala index 8b8dc2cad..2145c7831 100644 --- a/obp-api/src/main/scala/code/abacrule/AbacRuleEngine.scala +++ b/obp-api/src/main/scala/code/abacrule/AbacRuleEngine.scala @@ -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 * diff --git a/obp-api/src/main/scala/code/abacrule/AbacRuleTrait.scala b/obp-api/src/main/scala/code/abacrule/AbacRuleTrait.scala index e4309f342..9e9a22885 100644 --- a/obp-api/src/main/scala/code/abacrule/AbacRuleTrait.scala +++ b/obp-api/src/main/scala/code/abacrule/AbacRuleTrait.scala @@ -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() diff --git a/obp-api/src/main/scala/code/api/constant/constant.scala b/obp-api/src/main/scala/code/api/constant/constant.scala index 73cee00a6..9816ad4a2 100644 --- a/obp-api/src/main/scala/code/api/constant/constant.scala +++ b/obp-api/src/main/scala/code/api/constant/constant.scala @@ -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" 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 ef4f6337b..677811329 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 @@ -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, 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 7e64eec36..55a92ef0f 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 @@ -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 )