From 188e81585f150e3b77d4c792ff3d6a66b38addc5 Mon Sep 17 00:00:00 2001 From: kaviska Date: Thu, 9 Jul 2026 08:24:05 +0530 Subject: [PATCH 1/5] Add symbolic value type support to rule management component Introduce a new SYMBOLIC value type across the rule metadata, management and evaluation modules, allowing a rule to store a placeholder value (e.g. LATEST_ANDROID) that is resolved to a concrete value at evaluation time. - metadata/management: add SYMBOLIC to the value type enums and the DEVICE_POLICY flow type; RuleBuilder resolves symbolic values and validates option membership per token for the 'in' operator - evaluation: add SymbolicValueResolver contract and registry plus an evaluateSymbolicExpression path; FieldExtractor maps symbolic fields to a numeric evaluation type - add unit tests covering symbolic and 'in' operator happy and failure paths --- .../rule/evaluation/api/model/FlowType.java | 3 +- .../api/model/RuleEvaluationResult.java | 19 +++ .../api/resolver/SymbolicValueResolver.java | 37 ++++++ .../SymbolicValueResolverRegistry.java | 78 +++++++++++++ .../internal/service/impl/FieldExtractor.java | 2 + .../service/impl/OperatorRegistry.java | 2 + .../impl/RuleEvaluationServiceImpl.java | 2 +- .../internal/service/impl/RuleEvaluator.java | 57 ++++++++- .../rule/management/api/model/FlowType.java | 3 +- .../rule/management/api/model/Value.java | 2 +- .../rule/management/api/util/RuleBuilder.java | 53 +++++++-- .../rule/management/util/RuleBuilderTest.java | 108 +++++++++++++++++- .../resources/configs/valid-operators.json | 4 + .../rule/metadata/api/model/FlowType.java | 3 +- .../rule/metadata/api/model/Value.java | 2 +- 15 files changed, 354 insertions(+), 21 deletions(-) create mode 100644 components/rule-mgt/org.wso2.carbon.identity.rule.evaluation/src/main/java/org/wso2/carbon/identity/rule/evaluation/api/resolver/SymbolicValueResolver.java create mode 100644 components/rule-mgt/org.wso2.carbon.identity.rule.evaluation/src/main/java/org/wso2/carbon/identity/rule/evaluation/api/resolver/SymbolicValueResolverRegistry.java diff --git a/components/rule-mgt/org.wso2.carbon.identity.rule.evaluation/src/main/java/org/wso2/carbon/identity/rule/evaluation/api/model/FlowType.java b/components/rule-mgt/org.wso2.carbon.identity.rule.evaluation/src/main/java/org/wso2/carbon/identity/rule/evaluation/api/model/FlowType.java index 2d3bbbb7c96b..e8e0d2f9cea3 100644 --- a/components/rule-mgt/org.wso2.carbon.identity.rule.evaluation/src/main/java/org/wso2/carbon/identity/rule/evaluation/api/model/FlowType.java +++ b/components/rule-mgt/org.wso2.carbon.identity.rule.evaluation/src/main/java/org/wso2/carbon/identity/rule/evaluation/api/model/FlowType.java @@ -27,5 +27,6 @@ public enum FlowType { PRE_UPDATE_PASSWORD, PRE_UPDATE_PROFILE, PRE_ISSUE_ID_TOKEN, - APPROVAL_WORKFLOW + APPROVAL_WORKFLOW, + DEVICE_POLICY } diff --git a/components/rule-mgt/org.wso2.carbon.identity.rule.evaluation/src/main/java/org/wso2/carbon/identity/rule/evaluation/api/model/RuleEvaluationResult.java b/components/rule-mgt/org.wso2.carbon.identity.rule.evaluation/src/main/java/org/wso2/carbon/identity/rule/evaluation/api/model/RuleEvaluationResult.java index 022550c0d7ca..12efec21931d 100644 --- a/components/rule-mgt/org.wso2.carbon.identity.rule.evaluation/src/main/java/org/wso2/carbon/identity/rule/evaluation/api/model/RuleEvaluationResult.java +++ b/components/rule-mgt/org.wso2.carbon.identity.rule.evaluation/src/main/java/org/wso2/carbon/identity/rule/evaluation/api/model/RuleEvaluationResult.java @@ -18,6 +18,9 @@ package org.wso2.carbon.identity.rule.evaluation.api.model; +import java.util.Collections; +import java.util.List; + /** * Rule evaluation result. * This class is used to represent the result of a rule evaluation. @@ -26,11 +29,18 @@ public class RuleEvaluationResult { private final String ruleId; private final boolean ruleSatisfied; + private final List failedFields; public RuleEvaluationResult(String ruleId, boolean ruleSatisfied) { + this(ruleId, ruleSatisfied, Collections.emptyList()); + } + + public RuleEvaluationResult(String ruleId, boolean ruleSatisfied, List failedFields) { + this.ruleId = ruleId; this.ruleSatisfied = ruleSatisfied; + this.failedFields = failedFields != null ? failedFields : Collections.emptyList(); } public String getRuleId() { @@ -42,4 +52,13 @@ public boolean isRuleSatisfied() { return ruleSatisfied; } + + /** + * Returns the list of fields that failed evaluation. + * Empty when the rule is satisfied. + */ + public List getFailedFields() { + + return Collections.unmodifiableList(failedFields); + } } diff --git a/components/rule-mgt/org.wso2.carbon.identity.rule.evaluation/src/main/java/org/wso2/carbon/identity/rule/evaluation/api/resolver/SymbolicValueResolver.java b/components/rule-mgt/org.wso2.carbon.identity.rule.evaluation/src/main/java/org/wso2/carbon/identity/rule/evaluation/api/resolver/SymbolicValueResolver.java new file mode 100644 index 000000000000..7974ae99bee5 --- /dev/null +++ b/components/rule-mgt/org.wso2.carbon.identity.rule.evaluation/src/main/java/org/wso2/carbon/identity/rule/evaluation/api/resolver/SymbolicValueResolver.java @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.wso2.carbon.identity.rule.evaluation.api.resolver; + +import org.wso2.carbon.identity.rule.management.api.model.Value; + +/** + * Resolves a symbolic field value to a concrete {@link Value} at rule evaluation time. + * Implementations are registered per field name via {@link SymbolicValueResolverRegistry}. + */ +public interface SymbolicValueResolver { + + /** + * Resolves the given symbolic string to a concrete Value. + * The returned Value must have type NUMBER or LIST. + * + * @param symbolicValue Symbolic token or comma-separated list of tokens to resolve. + * @return Resolved Value with type NUMBER or LIST. + */ + Value resolve(String symbolicValue); +} diff --git a/components/rule-mgt/org.wso2.carbon.identity.rule.evaluation/src/main/java/org/wso2/carbon/identity/rule/evaluation/api/resolver/SymbolicValueResolverRegistry.java b/components/rule-mgt/org.wso2.carbon.identity.rule.evaluation/src/main/java/org/wso2/carbon/identity/rule/evaluation/api/resolver/SymbolicValueResolverRegistry.java new file mode 100644 index 000000000000..03127cb894c1 --- /dev/null +++ b/components/rule-mgt/org.wso2.carbon.identity.rule.evaluation/src/main/java/org/wso2/carbon/identity/rule/evaluation/api/resolver/SymbolicValueResolverRegistry.java @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.wso2.carbon.identity.rule.evaluation.api.resolver; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Singleton registry for {@link SymbolicValueResolver} instances, keyed by field name. + * Bundles register resolvers on OSGi bundle activation and deregister on deactivation. + */ +public class SymbolicValueResolverRegistry { + + private static final SymbolicValueResolverRegistry INSTANCE = new SymbolicValueResolverRegistry(); + private final Map resolvers = new ConcurrentHashMap<>(); + + private SymbolicValueResolverRegistry() { + + } + + /** + * Returns the singleton instance of this registry. + * + * @return Singleton SymbolicValueResolverRegistry. + */ + public static SymbolicValueResolverRegistry getInstance() { + + return INSTANCE; + } + + /** + * Registers a resolver for the given field name. + * + * @param fieldName Field name to associate the resolver with. + * @param resolver Resolver implementation. + */ + public void register(String fieldName, SymbolicValueResolver resolver) { + + resolvers.put(fieldName, resolver); + } + + /** + * Removes the resolver registered for the given field name. + * + * @param fieldName Field name whose resolver should be removed. + */ + public void deregister(String fieldName) { + + resolvers.remove(fieldName); + } + + /** + * Returns the resolver for the given field name, or null if none is registered. + * + * @param fieldName Field name. + * @return Registered resolver, or null if not found. + */ + public SymbolicValueResolver getResolver(String fieldName) { + + return resolvers.get(fieldName); + } +} diff --git a/components/rule-mgt/org.wso2.carbon.identity.rule.evaluation/src/main/java/org/wso2/carbon/identity/rule/evaluation/internal/service/impl/FieldExtractor.java b/components/rule-mgt/org.wso2.carbon.identity.rule.evaluation/src/main/java/org/wso2/carbon/identity/rule/evaluation/internal/service/impl/FieldExtractor.java index cedf9503471f..20ff408dba23 100644 --- a/components/rule-mgt/org.wso2.carbon.identity.rule.evaluation/src/main/java/org/wso2/carbon/identity/rule/evaluation/internal/service/impl/FieldExtractor.java +++ b/components/rule-mgt/org.wso2.carbon.identity.rule.evaluation/src/main/java/org/wso2/carbon/identity/rule/evaluation/internal/service/impl/FieldExtractor.java @@ -91,6 +91,8 @@ private ValueType resolveValueType(Value fieldDefinitionValue) { case REFERENCE: OptionsReferenceValue referenceValue = (OptionsReferenceValue) fieldDefinitionValue; return ValueType.createReferenceType(referenceValue.getValueReferenceAttribute()); + case SYMBOLIC: + return ValueType.NUMBER; default: throw new IllegalArgumentException("Unsupported value type: " + fieldDefinitionValueType); } diff --git a/components/rule-mgt/org.wso2.carbon.identity.rule.evaluation/src/main/java/org/wso2/carbon/identity/rule/evaluation/internal/service/impl/OperatorRegistry.java b/components/rule-mgt/org.wso2.carbon.identity.rule.evaluation/src/main/java/org/wso2/carbon/identity/rule/evaluation/internal/service/impl/OperatorRegistry.java index 630f0c78e6ec..b87b1b47d3a3 100644 --- a/components/rule-mgt/org.wso2.carbon.identity.rule.evaluation/src/main/java/org/wso2/carbon/identity/rule/evaluation/internal/service/impl/OperatorRegistry.java +++ b/components/rule-mgt/org.wso2.carbon.identity.rule.evaluation/src/main/java/org/wso2/carbon/identity/rule/evaluation/internal/service/impl/OperatorRegistry.java @@ -45,6 +45,8 @@ public class OperatorRegistry { supportedOperators.put("endsWith", stringPredicate(String::endsWith)); supportedOperators.put("greaterThan", comparablePredicate(result -> result > 0)); supportedOperators.put("lessThan", comparablePredicate(result -> result < 0)); + supportedOperators.put("greaterThanOrEquals", comparablePredicate(result -> result >= 0)); + supportedOperators.put("in", stringPredicate((a, b) -> java.util.Arrays.asList(b.split(",")).contains(a))); } private OperatorRegistry() { diff --git a/components/rule-mgt/org.wso2.carbon.identity.rule.evaluation/src/main/java/org/wso2/carbon/identity/rule/evaluation/internal/service/impl/RuleEvaluationServiceImpl.java b/components/rule-mgt/org.wso2.carbon.identity.rule.evaluation/src/main/java/org/wso2/carbon/identity/rule/evaluation/internal/service/impl/RuleEvaluationServiceImpl.java index 854f1bc36c60..ae464846c0f3 100644 --- a/components/rule-mgt/org.wso2.carbon.identity.rule.evaluation/src/main/java/org/wso2/carbon/identity/rule/evaluation/internal/service/impl/RuleEvaluationServiceImpl.java +++ b/components/rule-mgt/org.wso2.carbon.identity.rule.evaluation/src/main/java/org/wso2/carbon/identity/rule/evaluation/internal/service/impl/RuleEvaluationServiceImpl.java @@ -79,7 +79,7 @@ public RuleEvaluationResult evaluate(String ruleId, FlowContext flowContext, Str boolean evaluationStatus = ruleEvaluator.evaluate(rule, evaluationData); LOG.debug("Evaluated rule: " + rule.getId() + " to: " + evaluationStatus + "."); - return new RuleEvaluationResult(ruleId, evaluationStatus); + return new RuleEvaluationResult(ruleId, evaluationStatus, ruleEvaluator.getFailedFields()); } private Map getEvaluationData(String ruleId, FlowContext flowContext, diff --git a/components/rule-mgt/org.wso2.carbon.identity.rule.evaluation/src/main/java/org/wso2/carbon/identity/rule/evaluation/internal/service/impl/RuleEvaluator.java b/components/rule-mgt/org.wso2.carbon.identity.rule.evaluation/src/main/java/org/wso2/carbon/identity/rule/evaluation/internal/service/impl/RuleEvaluator.java index 3aa2b4b03bdf..088edfae61cf 100644 --- a/components/rule-mgt/org.wso2.carbon.identity.rule.evaluation/src/main/java/org/wso2/carbon/identity/rule/evaluation/internal/service/impl/RuleEvaluator.java +++ b/components/rule-mgt/org.wso2.carbon.identity.rule.evaluation/src/main/java/org/wso2/carbon/identity/rule/evaluation/internal/service/impl/RuleEvaluator.java @@ -23,11 +23,17 @@ import org.wso2.carbon.identity.rule.evaluation.api.exception.RuleEvaluationException; import org.wso2.carbon.identity.rule.evaluation.api.model.FieldValue; import org.wso2.carbon.identity.rule.evaluation.api.model.Operator; +import org.wso2.carbon.identity.rule.evaluation.api.resolver.SymbolicValueResolver; +import org.wso2.carbon.identity.rule.evaluation.api.resolver.SymbolicValueResolverRegistry; import org.wso2.carbon.identity.rule.management.api.model.ANDCombinedRule; import org.wso2.carbon.identity.rule.management.api.model.Expression; import org.wso2.carbon.identity.rule.management.api.model.ORCombinedRule; import org.wso2.carbon.identity.rule.management.api.model.Rule; +import org.wso2.carbon.identity.rule.management.api.model.Value; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; import java.util.List; import java.util.Map; @@ -46,11 +52,13 @@ public class RuleEvaluator { private static final Log LOG = LogFactory.getLog(RuleEvaluator.class); private final OperatorRegistry operatorRegistry; + private final List failedFields = new ArrayList<>(); // Operators private static final String EQUALS = "equals"; private static final String NOT_EQUALS = "notEquals"; private static final String CONTAINS = "contains"; + private static final String IN = "in"; public RuleEvaluator(OperatorRegistry operatorRegistry) { @@ -71,22 +79,32 @@ public boolean evaluate(Rule rule, Map evaluationData) throw return evaluateORCombinedRule(orRule, evaluationData); } + public List getFailedFields() { + + return Collections.unmodifiableList(failedFields); + } + private boolean evaluateORCombinedRule(ORCombinedRule orRule, Map evaluationData) throws RuleEvaluationException { for (ANDCombinedRule andRule : orRule.getRules()) { - if (evaluateANDCombinedRule(andRule, evaluationData)) { + List branchFailedFields = new ArrayList<>(); + if (evaluateANDCombinedRule(andRule, evaluationData, branchFailedFields)) { + failedFields.clear(); return true; // If any ANDCombinedRule evaluates to true, the ORCombinedRule passes } + failedFields.addAll(branchFailedFields); } return false; // If none of the ANDCombinedRules pass, the ORCombinedRule fails } - private boolean evaluateANDCombinedRule(ANDCombinedRule andRule, Map evaluationData) + private boolean evaluateANDCombinedRule(ANDCombinedRule andRule, Map evaluationData, + List branchFailedFields) throws RuleEvaluationException { for (Expression expression : andRule.getExpressions()) { if (!evaluateExpression(expression, evaluationData)) { + branchFailedFields.add(expression.getField()); return false; // If any expression fails, the ANDCombinedRule fails } } @@ -103,6 +121,10 @@ private boolean evaluateExpression(Expression expression, Map v.equals(deviceValue)); + } + + private boolean evaluateSymbolicExpression(Expression expression, FieldValue fieldValue, Operator operator) + throws RuleEvaluationException { + + SymbolicValueResolver resolver = SymbolicValueResolverRegistry.getInstance() + .getResolver(expression.getField()); + if (resolver == null) { + throw new RuleEvaluationException( + "No symbolic resolver registered for field: " + expression.getField()); + } + Value resolved = resolver.resolve(expression.getValue().getFieldValue()); + if (resolved.getType() == Value.Type.NUMBER) { + return operator.apply(fieldValue.getValue(), Double.parseDouble(resolved.getFieldValue())); + } else if (resolved.getType() == Value.Type.LIST) { + return evaluateInForNumber((Double) fieldValue.getValue(), resolved.getFieldValue()); + } + throw new RuleEvaluationException( + "Symbolic resolver returned unsupported type: " + resolved.getType()); + } + private boolean applyOperatorForList(Operator operator, Object fieldValue, Object expressionValue) { List list = (List) fieldValue; diff --git a/components/rule-mgt/org.wso2.carbon.identity.rule.management/src/main/java/org/wso2/carbon/identity/rule/management/api/model/FlowType.java b/components/rule-mgt/org.wso2.carbon.identity.rule.management/src/main/java/org/wso2/carbon/identity/rule/management/api/model/FlowType.java index acabed9b4020..92bca4c1ad02 100644 --- a/components/rule-mgt/org.wso2.carbon.identity.rule.management/src/main/java/org/wso2/carbon/identity/rule/management/api/model/FlowType.java +++ b/components/rule-mgt/org.wso2.carbon.identity.rule.management/src/main/java/org/wso2/carbon/identity/rule/management/api/model/FlowType.java @@ -27,5 +27,6 @@ public enum FlowType { PRE_UPDATE_PASSWORD, PRE_UPDATE_PROFILE, PRE_ISSUE_ID_TOKEN, - APPROVAL_WORKFLOW + APPROVAL_WORKFLOW, + DEVICE_POLICY } diff --git a/components/rule-mgt/org.wso2.carbon.identity.rule.management/src/main/java/org/wso2/carbon/identity/rule/management/api/model/Value.java b/components/rule-mgt/org.wso2.carbon.identity.rule.management/src/main/java/org/wso2/carbon/identity/rule/management/api/model/Value.java index 73045f49cb95..6e9713e22cb9 100644 --- a/components/rule-mgt/org.wso2.carbon.identity.rule.management/src/main/java/org/wso2/carbon/identity/rule/management/api/model/Value.java +++ b/components/rule-mgt/org.wso2.carbon.identity.rule.management/src/main/java/org/wso2/carbon/identity/rule/management/api/model/Value.java @@ -50,6 +50,6 @@ public String getFieldValue() { * Represents the type of the value. */ public enum Type { - STRING, NUMBER, BOOLEAN, DATE_TIME, REFERENCE, RAW + STRING, NUMBER, BOOLEAN, DATE_TIME, REFERENCE, RAW, LIST, SYMBOLIC } } diff --git a/components/rule-mgt/org.wso2.carbon.identity.rule.management/src/main/java/org/wso2/carbon/identity/rule/management/api/util/RuleBuilder.java b/components/rule-mgt/org.wso2.carbon.identity.rule.management/src/main/java/org/wso2/carbon/identity/rule/management/api/util/RuleBuilder.java index f521bf37e3c7..9efe24fa29bc 100644 --- a/components/rule-mgt/org.wso2.carbon.identity.rule.management/src/main/java/org/wso2/carbon/identity/rule/management/api/util/RuleBuilder.java +++ b/components/rule-mgt/org.wso2.carbon.identity.rule.management/src/main/java/org/wso2/carbon/identity/rule/management/api/util/RuleBuilder.java @@ -43,7 +43,7 @@ */ public class RuleBuilder { - private static final int MAX_EXPRESSIONS_COMBINED_WITH_AND = 5; + private static final int MAX_EXPRESSIONS_COMBINED_WITH_AND = 15; private static final int MAX_RULES_COMBINED_WITH_OR = 10; private final ORCombinedRule.Builder orCombinedRuleBuilder = new ORCombinedRule.Builder(); @@ -241,12 +241,12 @@ private Value validateAndResolveValue(FieldDefinition fieldDefinition, Value val return value; } - if (!isValidOptionsInputValue(fieldDefinition, rawValue)) { + if (!isValidOptionsInputValue(fieldDefinition, rawValue, value.getType())) { return value; } try { - return resolveValue(fieldDefinition, rawValue); + return resolveValue(fieldDefinition, rawValue, value); } catch (RuleManagementClientException e) { setValidationError(e.getMessage()); return value; @@ -256,9 +256,21 @@ private Value validateAndResolveValue(FieldDefinition fieldDefinition, Value val private Value resolveValue(FieldDefinition fieldDefinition, String rawValue) throws RuleManagementClientException { + return resolveValue(fieldDefinition, rawValue, null); + } + + private Value resolveValue(FieldDefinition fieldDefinition, + String rawValue, Value originalValue) throws RuleManagementClientException { + org.wso2.carbon.identity.rule.metadata.api.model.Value.ValueType fieldDefinitionValueType = fieldDefinition.getValue().getValueType(); + boolean isSymbolicField = fieldDefinitionValueType == + org.wso2.carbon.identity.rule.metadata.api.model.Value.ValueType.SYMBOLIC; + if (originalValue != null && originalValue.getType() == Value.Type.LIST && !isSymbolicField) { + return new Value(Value.Type.LIST, rawValue); + } + switch (fieldDefinitionValueType) { case STRING: return new Value(Value.Type.STRING, rawValue); @@ -268,6 +280,8 @@ private Value resolveValue(FieldDefinition fieldDefinition, return validateBooleanValue(rawValue); case REFERENCE: return new Value(Value.Type.REFERENCE, rawValue); + case SYMBOLIC: + return new Value(Value.Type.SYMBOLIC, rawValue); default: throw new RuleManagementClientException( "Unsupported value type: " + fieldDefinitionValueType + " for field: " + @@ -281,7 +295,11 @@ private boolean isValidValueType(FieldDefinition fieldDefinition, Value value) { fieldDefinition.getValue().getValueType(); Value.Type valueType = value.getType(); - if (valueType != Value.Type.RAW && !valueType.name().equals(fieldDefinitionValueType.name())) { + // RAW and LIST bypass strict type matching — RAW is stored as-is, LIST is for multi-value (in) operator. + if (valueType == Value.Type.RAW || valueType == Value.Type.LIST) { + return true; + } + if (!valueType.name().equals(fieldDefinitionValueType.name())) { setValidationError( "Value type " + valueType + " is not supported for field " + fieldDefinition.getField().getName()); return false; @@ -289,16 +307,29 @@ private boolean isValidValueType(FieldDefinition fieldDefinition, Value value) { return true; } - private boolean isValidOptionsInputValue(FieldDefinition fieldDefinition, String fieldValue) { + private boolean isValidOptionsInputValue(FieldDefinition fieldDefinition, String fieldValue, + Value.Type valueType) { - if (fieldDefinition.getValue() instanceof OptionsInputValue && - ((OptionsInputValue) fieldDefinition.getValue()).getValues().stream() - .noneMatch(optionsValue -> optionsValue.getName().equals(fieldValue))) { - setValidationError( - "Value " + fieldValue + " is not supported for field " + fieldDefinition.getField().getName()); - return false; + // Only fields backed by a fixed set of options need membership validation. + if (!(fieldDefinition.getValue() instanceof OptionsInputValue)) { + return true; } + OptionsInputValue optionsInputValue = (OptionsInputValue) fieldDefinition.getValue(); + + // The 'in' operator packs multiple comma-separated values into a single LIST string; validate each + // item individually. A single-value operator has exactly one item to validate. + String[] items = valueType == Value.Type.LIST ? fieldValue.split(",") : new String[] {fieldValue}; + + for (String item : items) { + String token = item.trim(); + if (optionsInputValue.getValues().stream() + .noneMatch(optionsValue -> optionsValue.getName().equals(token))) { + setValidationError( + "Value " + token + " is not supported for field " + fieldDefinition.getField().getName()); + return false; + } + } return true; } diff --git a/components/rule-mgt/org.wso2.carbon.identity.rule.management/src/test/java/org/wso2/carbon/identity/rule/management/util/RuleBuilderTest.java b/components/rule-mgt/org.wso2.carbon.identity.rule.management/src/test/java/org/wso2/carbon/identity/rule/management/util/RuleBuilderTest.java index 27df8cf76829..6a0bc3385e83 100644 --- a/components/rule-mgt/org.wso2.carbon.identity.rule.management/src/test/java/org/wso2/carbon/identity/rule/management/util/RuleBuilderTest.java +++ b/components/rule-mgt/org.wso2.carbon.identity.rule.management/src/test/java/org/wso2/carbon/identity/rule/management/util/RuleBuilderTest.java @@ -372,7 +372,7 @@ public void testCreateRuleWithMultipleValidationFailures() throws Exception { @Test(expectedExceptions = RuleManagementClientException.class, expectedExceptionsMessageRegExp = "Rule validation failed: " + - "Maximum number of expressions combined with AND exceeded. Maximum allowed: 5 Provided: 6") + "Maximum number of expressions combined with AND exceeded. Maximum allowed: 15 Provided: 16") public void testCreateRuleWithMaxAllowedExpressionsCombinedWithAND() throws Exception { List mockedFieldDefinitions = getMockedFieldDefinitions(); @@ -382,7 +382,7 @@ public void testCreateRuleWithMaxAllowedExpressionsCombinedWithAND() throws Exce RuleBuilder ruleBuilder = RuleBuilder.create(FlowType.PRE_ISSUE_ACCESS_TOKEN, "tenant1"); - for (int i = 0; i < 6; i++) { + for (int i = 0; i < 16; i++) { Expression expression = new Expression.Builder().field("application").operator("equals") .value(new Value(Value.Type.REFERENCE, "testapp" + i)).build(); ruleBuilder.addAndExpression(expression); @@ -502,6 +502,110 @@ public void testCreateRuleWithMultipleExpressionsUsingSameFieldReferenceAndOR() assertExpressions(andCombinedRule, expression5); } + @Test + public void testCreateRuleWithInOperatorAndValidSymbolicValues() throws Exception { + + List mockedFieldDefinitions = getMockedFieldDefinitionsWithSymbolicField(); + when(ruleMetadataService.getExpressionMeta( + org.wso2.carbon.identity.rule.metadata.api.model.FlowType.PRE_ISSUE_ACCESS_TOKEN, "tenant1")) + .thenReturn(mockedFieldDefinitions); + + RuleBuilder ruleBuilder = RuleBuilder.create(FlowType.PRE_ISSUE_ACCESS_TOKEN, "tenant1"); + + Expression expression = new Expression.Builder().field("osVersion").operator("in") + .value(new Value(Value.Type.LIST, "LATEST_ANDROID,SECOND_LATEST_ANDROID")).build(); + ruleBuilder.addAndExpression(expression); + Rule rule = ruleBuilder.build(); + + assertNotNull(rule); + Expression resolved = + assertAndCombinedRule(assertOrCombinedRule(rule, 1).getRules().get(0), 1).getExpressions().get(0); + // The 'in' multi-value on a symbolic field is resolved to a SYMBOLIC value, preserving the raw tokens. + assertEquals(resolved.getValue().getType(), Value.Type.SYMBOLIC); + assertEquals(resolved.getValue().getFieldValue(), "LATEST_ANDROID,SECOND_LATEST_ANDROID"); + } + + @Test + public void testCreateRuleWithEqualsOperatorAndValidSymbolicValue() throws Exception { + + List mockedFieldDefinitions = getMockedFieldDefinitionsWithSymbolicField(); + when(ruleMetadataService.getExpressionMeta( + org.wso2.carbon.identity.rule.metadata.api.model.FlowType.PRE_ISSUE_ACCESS_TOKEN, "tenant1")) + .thenReturn(mockedFieldDefinitions); + + RuleBuilder ruleBuilder = RuleBuilder.create(FlowType.PRE_ISSUE_ACCESS_TOKEN, "tenant1"); + + Expression expression = new Expression.Builder().field("osVersion").operator("equals") + .value("LATEST_ANDROID").build(); + ruleBuilder.addAndExpression(expression); + Rule rule = ruleBuilder.build(); + + assertNotNull(rule); + Expression resolved = + assertAndCombinedRule(assertOrCombinedRule(rule, 1).getRules().get(0), 1).getExpressions().get(0); + // A single valid symbolic token is resolved to a SYMBOLIC value. + assertEquals(resolved.getValue().getType(), Value.Type.SYMBOLIC); + assertEquals(resolved.getValue().getFieldValue(), "LATEST_ANDROID"); + } + + @Test(expectedExceptions = RuleManagementClientException.class, + expectedExceptionsMessageRegExp = "Rule validation failed: " + + "Value INVALID_VERSION is not supported for field osVersion") + public void testCreateRuleWithInOperatorAndInvalidSymbolicValue() throws Exception { + + List mockedFieldDefinitions = getMockedFieldDefinitionsWithSymbolicField(); + when(ruleMetadataService.getExpressionMeta( + org.wso2.carbon.identity.rule.metadata.api.model.FlowType.PRE_ISSUE_ACCESS_TOKEN, "tenant1")) + .thenReturn(mockedFieldDefinitions); + + RuleBuilder ruleBuilder = RuleBuilder.create(FlowType.PRE_ISSUE_ACCESS_TOKEN, "tenant1"); + + // One valid token and one invalid token; the invalid one must be rejected at build time. + Expression expression = new Expression.Builder().field("osVersion").operator("in") + .value(new Value(Value.Type.LIST, "LATEST_ANDROID,INVALID_VERSION")).build(); + ruleBuilder.addAndExpression(expression); + + ruleBuilder.build(); + } + + @Test(expectedExceptions = RuleManagementClientException.class, + expectedExceptionsMessageRegExp = "Rule validation failed: " + + "Value INVALID_VERSION is not supported for field osVersion") + public void testCreateRuleWithEqualsOperatorAndInvalidSymbolicValue() throws Exception { + + List mockedFieldDefinitions = getMockedFieldDefinitionsWithSymbolicField(); + when(ruleMetadataService.getExpressionMeta( + org.wso2.carbon.identity.rule.metadata.api.model.FlowType.PRE_ISSUE_ACCESS_TOKEN, "tenant1")) + .thenReturn(mockedFieldDefinitions); + + RuleBuilder ruleBuilder = RuleBuilder.create(FlowType.PRE_ISSUE_ACCESS_TOKEN, "tenant1"); + + Expression expression = new Expression.Builder().field("osVersion").operator("equals") + .value("INVALID_VERSION").build(); + ruleBuilder.addAndExpression(expression); + + ruleBuilder.build(); + } + + private List getMockedFieldDefinitionsWithSymbolicField() { + + List fieldDefinitionList = new ArrayList<>(); + + Field osVersionField = new Field("osVersion", "OS Version"); + List operators = Arrays.asList(new Operator("equals", "equals"), new Operator("in", "in")); + List osVersionOptions = Arrays.asList( + new OptionsValue("LATEST_ANDROID", "Latest Android"), + new OptionsValue("SECOND_LATEST_ANDROID", "Second Latest Android"), + new OptionsValue("13", "Android 13"), + new OptionsValue("14", "Android 14")); + org.wso2.carbon.identity.rule.metadata.api.model.Value osVersionValue = + new OptionsInputValue(org.wso2.carbon.identity.rule.metadata.api.model.Value.ValueType.SYMBOLIC, + osVersionOptions); + fieldDefinitionList.add(new FieldDefinition(osVersionField, operators, osVersionValue)); + + return fieldDefinitionList; + } + private List getMockedFieldDefinitions() { List fieldDefinitionList = new ArrayList<>(); diff --git a/components/rule-mgt/org.wso2.carbon.identity.rule.management/src/test/resources/configs/valid-operators.json b/components/rule-mgt/org.wso2.carbon.identity.rule.management/src/test/resources/configs/valid-operators.json index 440832b8f460..d46bdcb4f390 100644 --- a/components/rule-mgt/org.wso2.carbon.identity.rule.management/src/test/resources/configs/valid-operators.json +++ b/components/rule-mgt/org.wso2.carbon.identity.rule.management/src/test/resources/configs/valid-operators.json @@ -10,5 +10,9 @@ "contains": { "name": "contains", "displayName": "contains" + }, + "in": { + "name": "in", + "displayName": "in" } } diff --git a/components/rule-mgt/org.wso2.carbon.identity.rule.metadata/src/main/java/org/wso2/carbon/identity/rule/metadata/api/model/FlowType.java b/components/rule-mgt/org.wso2.carbon.identity.rule.metadata/src/main/java/org/wso2/carbon/identity/rule/metadata/api/model/FlowType.java index 8ae54b1c1817..964863f9a7d4 100644 --- a/components/rule-mgt/org.wso2.carbon.identity.rule.metadata/src/main/java/org/wso2/carbon/identity/rule/metadata/api/model/FlowType.java +++ b/components/rule-mgt/org.wso2.carbon.identity.rule.metadata/src/main/java/org/wso2/carbon/identity/rule/metadata/api/model/FlowType.java @@ -29,7 +29,8 @@ public enum FlowType { PRE_UPDATE_PASSWORD("preUpdatePassword"), PRE_UPDATE_PROFILE("preUpdateProfile"), PRE_ISSUE_ID_TOKEN("preIssueIdToken"), - APPROVAL_WORKFLOW("approvalWorkflow"); + APPROVAL_WORKFLOW("approvalWorkflow"), + DEVICE_POLICY("devicePolicy"); private final String flowAlias; diff --git a/components/rule-mgt/org.wso2.carbon.identity.rule.metadata/src/main/java/org/wso2/carbon/identity/rule/metadata/api/model/Value.java b/components/rule-mgt/org.wso2.carbon.identity.rule.metadata/src/main/java/org/wso2/carbon/identity/rule/metadata/api/model/Value.java index c6d417923808..d8cbafe5f24e 100644 --- a/components/rule-mgt/org.wso2.carbon.identity.rule.metadata/src/main/java/org/wso2/carbon/identity/rule/metadata/api/model/Value.java +++ b/components/rule-mgt/org.wso2.carbon.identity.rule.metadata/src/main/java/org/wso2/carbon/identity/rule/metadata/api/model/Value.java @@ -57,6 +57,6 @@ public enum InputType { * Represents the value type of the value. */ public enum ValueType { - STRING, NUMBER, BOOLEAN, DATE_TIME, REFERENCE + STRING, NUMBER, BOOLEAN, DATE_TIME, REFERENCE, SYMBOLIC } } From 5de1bc545730b8f6928695bf1095354a86e73282 Mon Sep 17 00:00:00 2001 From: kaviska Date: Thu, 9 Jul 2026 08:27:04 +0530 Subject: [PATCH 2/5] Add rule metadata configuration for symbolic OS version fields Add the device policy flow metadata that backs the symbolic value support in the rule management server feature: - operators.json: add the 'in' and 'greaterThanOrEquals' operators - flows.json: add the devicePolicy flow (android/ios/macos/windows OS version) - fields.json: add the symbolic OS version field definitions --- .../resources/identity/rulemeta/fields.json | 544 ++++++++++++++++++ .../resources/identity/rulemeta/flows.json | 68 +++ .../identity/rulemeta/operators.json | 8 + 3 files changed, 620 insertions(+) diff --git a/features/rule-mgt/org.wso2.carbon.identity.rule.management.server.feature/resources/identity/rulemeta/fields.json b/features/rule-mgt/org.wso2.carbon.identity.rule.management.server.feature/resources/identity/rulemeta/fields.json index 6fae195294d3..54dd3401ca1f 100644 --- a/features/rule-mgt/org.wso2.carbon.identity.rule.management.server.feature/resources/identity/rulemeta/fields.json +++ b/features/rule-mgt/org.wso2.carbon.identity.rule.management.server.feature/resources/identity/rulemeta/fields.json @@ -79,5 +79,549 @@ } ] } + }, + "platform": { + "field": { + "name": "platform", + "displayName": "platform" + }, + "operators": [ + "equals", + "notEquals" + ], + "value": { + "inputType": "options", + "valueType": "string", + "values": [ + { + "name": "android", + "displayName": "Android" + }, + { + "name": "ios", + "displayName": "iOS" + }, + { + "name": "macos", + "displayName": "macOS" + }, + { + "name": "windows", + "displayName": "Windows" + } + ] + } + }, + "androidOsVersion": { + "field": { + "name": "androidOsVersion", + "displayName": "Android OS Version" + }, + "operators": [ + "equals", + "greaterThanOrEquals", + "in" + ], + "value": { + "inputType": "options", + "valueType": "symbolic", + "values": [ + { "name": "LATEST_ANDROID", "displayName": "Latest Android" }, + { "name": "SECOND_LATEST_ANDROID", "displayName": "Second Latest Android" }, + { "name": "9", "displayName": "Android 9" }, + { "name": "10", "displayName": "Android 10" }, + { "name": "11", "displayName": "Android 11" }, + { "name": "12", "displayName": "Android 12" }, + { "name": "13", "displayName": "Android 13" }, + { "name": "14", "displayName": "Android 14" }, + { "name": "15", "displayName": "Android 15" }, + { "name": "16", "displayName": "Android 16" }, + { "name": "17", "displayName": "Android 17" } + ] + } + }, + "iosOsVersion": { + "field": { + "name": "iosOsVersion", + "displayName": "iOS Version" + }, + "operators": [ + "equals", + "greaterThanOrEquals", + "in" + ], + "value": { + "inputType": "options", + "valueType": "symbolic", + "values": [ + { "name": "LATEST_IOS", "displayName": "Latest iOS" }, + { "name": "SECOND_LATEST_IOS", "displayName": "Second Latest iOS" }, + { "name": "14", "displayName": "iOS 14" }, + { "name": "15", "displayName": "iOS 15" }, + { "name": "16", "displayName": "iOS 16" }, + { "name": "17", "displayName": "iOS 17" }, + { "name": "18", "displayName": "iOS 18" }, + { "name": "19", "displayName": "iOS 19" }, + { "name": "20", "displayName": "iOS 20" }, + { "name": "21", "displayName": "iOS 21" }, + { "name": "22", "displayName": "iOS 22" }, + { "name": "23", "displayName": "iOS 23" }, + { "name": "24", "displayName": "iOS 24" }, + { "name": "25", "displayName": "iOS 25" }, + { "name": "26", "displayName": "iOS 26" } + ] + } + }, + "macosOsVersion": { + "field": { + "name": "macosOsVersion", + "displayName": "macOS Version" + }, + "operators": [ + "equals", + "greaterThanOrEquals", + "in" + ], + "value": { + "inputType": "options", + "valueType": "symbolic", + "values": [ + { "name": "LATEST_MACOS", "displayName": "Latest macOS" }, + { "name": "SECOND_LATEST_MACOS", "displayName": "Second Latest macOS" }, + { "name": "12", "displayName": "macOS 12 (Monterey)" }, + { "name": "13", "displayName": "macOS 13 (Ventura)" }, + { "name": "14", "displayName": "macOS 14 (Sonoma)" }, + { "name": "15", "displayName": "macOS 15 (Sequoia)" } + ] + } + }, + "windowsOsVersion": { + "field": { + "name": "windowsOsVersion", + "displayName": "Windows Version" + }, + "operators": [ + "equals", + "greaterThanOrEquals", + "in" + ], + "value": { + "inputType": "options", + "valueType": "symbolic", + "values": [ + { "name": "LATEST_WINDOWS", "displayName": "Latest Windows" }, + { "name": "SECOND_LATEST_WINDOWS", "displayName": "Second Latest Windows" }, + { "name": "10", "displayName": "Windows 10" }, + { "name": "11", "displayName": "Windows 11" } + ] + } + }, + "isRooted": { + "field": { + "name": "isRooted", + "displayName": "is rooted" + }, + "operators": [ + "equals" + ], + "value": { + "inputType": "options", + "valueType": "string", + "values": [ + { + "name": "true", + "displayName": "Yes" + }, + { + "name": "false", + "displayName": "No" + } + ] + } + }, + "usbDebugging": { + "field": { + "name": "usbDebugging", + "displayName": "USB debugging" + }, + "operators": [ + "equals" + ], + "value": { + "inputType": "options", + "valueType": "string", + "values": [ + { + "name": "true", + "displayName": "Enabled" + }, + { + "name": "false", + "displayName": "Disabled" + } + ] + } + }, + "lockScreen": { + "field": { + "name": "lockScreen", + "displayName": "lock screen" + }, + "operators": [ + "equals" + ], + "value": { + "inputType": "options", + "valueType": "string", + "values": [ + { + "name": "true", + "displayName": "Enabled" + }, + { + "name": "false", + "displayName": "Disabled" + } + ] + } + }, + "biometric": { + "field": { + "name": "biometric", + "displayName": "biometric" + }, + "operators": [ + "equals" + ], + "value": { + "inputType": "options", + "valueType": "string", + "values": [ + { + "name": "true", + "displayName": "Enabled" + }, + { + "name": "false", + "displayName": "Disabled" + } + ] + } + }, + "screenLockComplexity": { + "field": { + "name": "screenLockComplexity", + "displayName": "screen lock complexity" + }, + "operators": [ + "equals" + ], + "value": { + "inputType": "options", + "valueType": "string", + "values": [ + { + "name": "high", + "displayName": "High" + }, + { + "name": "medium", + "displayName": "Medium" + }, + { + "name": "low", + "displayName": "Low" + } + ] + } + }, + "hardwareKeystore": { + "field": { + "name": "hardwareKeystore", + "displayName": "hardware keystore" + }, + "operators": [ + "equals" + ], + "value": { + "inputType": "options", + "valueType": "string", + "values": [ + { + "name": "true", + "displayName": "Enabled" + }, + { + "name": "false", + "displayName": "Disabled" + } + ] + } + }, + "diskEncryption": { + "field": { + "name": "diskEncryption", + "displayName": "disk encryption" + }, + "operators": [ + "equals" + ], + "value": { + "inputType": "options", + "valueType": "string", + "values": [ + { + "name": "true", + "displayName": "Enabled" + }, + { + "name": "false", + "displayName": "Disabled" + } + ] + } + }, + "networkProxies": { + "field": { + "name": "networkProxies", + "displayName": "network proxies" + }, + "operators": [ + "equals" + ], + "value": { + "inputType": "options", + "valueType": "string", + "values": [ + { + "name": "true", + "displayName": "Enabled" + }, + { + "name": "false", + "displayName": "Disabled" + } + ] + } + }, + "wifiNetworkSecurity": { + "field": { + "name": "wifiNetworkSecurity", + "displayName": "WiFi network security" + }, + "operators": [ + "equals" + ], + "value": { + "inputType": "options", + "valueType": "string", + "values": [ + { + "name": "true", + "displayName": "Enabled" + }, + { + "name": "false", + "displayName": "Disabled" + } + ] + } + }, + "passcode": { + "field": { + "name": "passcode", + "displayName": "passcode" + }, + "operators": [ + "equals" + ], + "value": { + "inputType": "options", + "valueType": "string", + "values": [ + { + "name": "true", + "displayName": "Enabled" + }, + { + "name": "false", + "displayName": "Disabled" + } + ] + } + }, + "touchIdOrFaceId": { + "field": { + "name": "touchIdOrFaceId", + "displayName": "Touch ID or Face ID" + }, + "operators": [ + "equals" + ], + "value": { + "inputType": "options", + "valueType": "string", + "values": [ + { + "name": "true", + "displayName": "Enabled" + }, + { + "name": "false", + "displayName": "Disabled" + } + ] + } + }, + "jailbreak": { + "field": { + "name": "jailbreak", + "displayName": "jailbreak" + }, + "operators": [ + "equals" + ], + "value": { + "inputType": "options", + "valueType": "string", + "values": [ + { + "name": "true", + "displayName": "Yes" + }, + { + "name": "false", + "displayName": "No" + } + ] + } + }, + "secureEnclave": { + "field": { + "name": "secureEnclave", + "displayName": "secure enclave" + }, + "operators": [ + "equals" + ], + "value": { + "inputType": "options", + "valueType": "string", + "values": [ + { + "name": "true", + "displayName": "Enabled" + }, + { + "name": "false", + "displayName": "Disabled" + } + ] + } + }, + "windowsHello": { + "field": { + "name": "windowsHello", + "displayName": "Windows Hello" + }, + "operators": [ + "equals" + ], + "value": { + "inputType": "options", + "valueType": "string", + "values": [ + { + "name": "true", + "displayName": "Enabled" + }, + { + "name": "false", + "displayName": "Disabled" + } + ] + } + }, + "trustedPlatformModule": { + "field": { + "name": "trustedPlatformModule", + "displayName": "Trusted Platform Module" + }, + "operators": [ + "equals" + ], + "value": { + "inputType": "options", + "valueType": "string", + "values": [ + { + "name": "true", + "displayName": "Enabled" + }, + { + "name": "false", + "displayName": "Disabled" + } + ] + } + }, + "androidIntegrity": { + "field": { + "name": "androidIntegrity", + "displayName": "Android integrity" + }, + "operators": [ + "equals", + "notEquals" + ], + "value": { + "inputType": "options", + "valueType": "string", + "values": [ + { + "name": "MEETS_STRONG_INTEGRITY", + "displayName": "Meets Strong Integrity" + }, + { + "name": "MEETS_DEVICE_INTEGRITY", + "displayName": "Meets Device Integrity" + }, + { + "name": "MEETS_BASIC_INTEGRITY", + "displayName": "Meets Basic Integrity" + }, + { + "name": "MEETS_VIRTUAL_INTEGRITY", + "displayName": "Meets Virtual Integrity" + }, + { + "name": "INTEGRITY_FAILED", + "displayName": "Integrity Failed" + } + ] + } + }, + "iosIntegrity": { + "field": { + "name": "iosIntegrity", + "displayName": "iOS device genuine" + }, + "operators": [ + "equals" + ], + "value": { + "inputType": "options", + "valueType": "string", + "values": [ + { + "name": "true", + "displayName": "Yes" + }, + { + "name": "false", + "displayName": "No" + } + ] + } } } diff --git a/features/rule-mgt/org.wso2.carbon.identity.rule.management.server.feature/resources/identity/rulemeta/flows.json b/features/rule-mgt/org.wso2.carbon.identity.rule.management.server.feature/resources/identity/rulemeta/flows.json index 3a95ebc5c3ad..9210640f56e2 100644 --- a/features/rule-mgt/org.wso2.carbon.identity.rule.management.server.feature/resources/identity/rulemeta/flows.json +++ b/features/rule-mgt/org.wso2.carbon.identity.rule.management.server.feature/resources/identity/rulemeta/flows.json @@ -113,6 +113,74 @@ } } ], + "devicePolicy": [ + { + "fieldName": "platform" + }, + { + "fieldName": "androidOsVersion" + }, + { + "fieldName": "iosOsVersion" + }, + { + "fieldName": "macosOsVersion" + }, + { + "fieldName": "windowsOsVersion" + }, + { + "fieldName": "isRooted" + }, + { + "fieldName": "usbDebugging" + }, + { + "fieldName": "lockScreen" + }, + { + "fieldName": "biometric" + }, + { + "fieldName": "screenLockComplexity" + }, + { + "fieldName": "hardwareKeystore" + }, + { + "fieldName": "diskEncryption" + }, + { + "fieldName": "networkProxies" + }, + { + "fieldName": "wifiNetworkSecurity" + }, + { + "fieldName": "androidIntegrity" + }, + { + "fieldName": "iosIntegrity" + }, + { + "fieldName": "passcode" + }, + { + "fieldName": "touchIdOrFaceId" + }, + { + "fieldName": "jailbreak" + }, + { + "fieldName": "secureEnclave" + }, + { + "fieldName": "windowsHello" + }, + { + "fieldName": "trustedPlatformModule" + } + ], "preUpdateProfile": [ { "fieldName": "flow", diff --git a/features/rule-mgt/org.wso2.carbon.identity.rule.management.server.feature/resources/identity/rulemeta/operators.json b/features/rule-mgt/org.wso2.carbon.identity.rule.management.server.feature/resources/identity/rulemeta/operators.json index 61bd5555058f..060398c7987c 100644 --- a/features/rule-mgt/org.wso2.carbon.identity.rule.management.server.feature/resources/identity/rulemeta/operators.json +++ b/features/rule-mgt/org.wso2.carbon.identity.rule.management.server.feature/resources/identity/rulemeta/operators.json @@ -30,5 +30,13 @@ "lessThan": { "name": "lessThan", "displayName": "less than" + }, + "greaterThanOrEquals": { + "name": "greaterThanOrEquals", + "displayName": "greater than or equal to" + }, + "in": { + "name": "in", + "displayName": "in" } } From ca3593ef5e50c58dabb6c8ac0e8c8805d39ebf0c Mon Sep 17 00:00:00 2001 From: kaviska Date: Thu, 9 Jul 2026 10:07:06 +0530 Subject: [PATCH 3/5] Address review comments and add symbolic evaluation tests - OperatorRegistry: trim comma-separated tokens in the 'in' operator, for parity with evaluateInForNumber and the save-time option validation - RuleEvaluator: wrap numeric parsing in the 'in' evaluation so a non-numeric value throws a handled RuleEvaluationException instead of an uncaught NumberFormatException - RuleEvaluator: route symbolic expressions by the operator (in vs equals) instead of the resolved value shape, fixing incorrect results for 'in' with a single value and 'equals' with a multi-value - add unit tests covering the symbolic evaluation paths and the symbolic value resolver registry --- .../service/impl/OperatorRegistry.java | 3 +- .../internal/service/impl/RuleEvaluator.java | 29 +-- .../core/RuleEvaluatorSymbolicTest.java | 170 ++++++++++++++++++ .../SymbolicValueResolverRegistryTest.java | 54 ++++++ .../src/test/resources/testng.xml | 2 + 5 files changed, 247 insertions(+), 11 deletions(-) create mode 100644 components/rule-mgt/org.wso2.carbon.identity.rule.evaluation/src/test/java/org/wso2/carbon/identity/rule/evaluation/core/RuleEvaluatorSymbolicTest.java create mode 100644 components/rule-mgt/org.wso2.carbon.identity.rule.evaluation/src/test/java/org/wso2/carbon/identity/rule/evaluation/core/SymbolicValueResolverRegistryTest.java diff --git a/components/rule-mgt/org.wso2.carbon.identity.rule.evaluation/src/main/java/org/wso2/carbon/identity/rule/evaluation/internal/service/impl/OperatorRegistry.java b/components/rule-mgt/org.wso2.carbon.identity.rule.evaluation/src/main/java/org/wso2/carbon/identity/rule/evaluation/internal/service/impl/OperatorRegistry.java index b87b1b47d3a3..b8495a537926 100644 --- a/components/rule-mgt/org.wso2.carbon.identity.rule.evaluation/src/main/java/org/wso2/carbon/identity/rule/evaluation/internal/service/impl/OperatorRegistry.java +++ b/components/rule-mgt/org.wso2.carbon.identity.rule.evaluation/src/main/java/org/wso2/carbon/identity/rule/evaluation/internal/service/impl/OperatorRegistry.java @@ -46,7 +46,8 @@ public class OperatorRegistry { supportedOperators.put("greaterThan", comparablePredicate(result -> result > 0)); supportedOperators.put("lessThan", comparablePredicate(result -> result < 0)); supportedOperators.put("greaterThanOrEquals", comparablePredicate(result -> result >= 0)); - supportedOperators.put("in", stringPredicate((a, b) -> java.util.Arrays.asList(b.split(",")).contains(a))); + supportedOperators.put("in", stringPredicate((a, b) -> java.util.Arrays.stream(b.split(",")) + .map(String::trim).anyMatch(token -> token.equals(a)))); } private OperatorRegistry() { diff --git a/components/rule-mgt/org.wso2.carbon.identity.rule.evaluation/src/main/java/org/wso2/carbon/identity/rule/evaluation/internal/service/impl/RuleEvaluator.java b/components/rule-mgt/org.wso2.carbon.identity.rule.evaluation/src/main/java/org/wso2/carbon/identity/rule/evaluation/internal/service/impl/RuleEvaluator.java index 088edfae61cf..7b8ad2a5dcd2 100644 --- a/components/rule-mgt/org.wso2.carbon.identity.rule.evaluation/src/main/java/org/wso2/carbon/identity/rule/evaluation/internal/service/impl/RuleEvaluator.java +++ b/components/rule-mgt/org.wso2.carbon.identity.rule.evaluation/src/main/java/org/wso2/carbon/identity/rule/evaluation/internal/service/impl/RuleEvaluator.java @@ -146,12 +146,18 @@ private boolean evaluateExpression(Expression expression, Map v.equals(deviceValue)); + try { + return Arrays.stream(commaSeparated.split(",")) + .map(String::trim) + .map(Double::parseDouble) + .anyMatch(v -> v.equals(deviceValue)); + } catch (NumberFormatException e) { + throw new RuleEvaluationException( + "Failed to parse numeric value in 'in' expression: " + commaSeparated, e); + } } private boolean evaluateSymbolicExpression(Expression expression, FieldValue fieldValue, Operator operator) @@ -164,13 +170,16 @@ private boolean evaluateSymbolicExpression(Expression expression, FieldValue fie "No symbolic resolver registered for field: " + expression.getField()); } Value resolved = resolver.resolve(expression.getValue().getFieldValue()); - if (resolved.getType() == Value.Type.NUMBER) { - return operator.apply(fieldValue.getValue(), Double.parseDouble(resolved.getFieldValue())); - } else if (resolved.getType() == Value.Type.LIST) { + if (resolved.getType() != Value.Type.NUMBER && resolved.getType() != Value.Type.LIST) { + throw new RuleEvaluationException( + "Symbolic resolver returned unsupported type: " + resolved.getType()); + } + + // Route on the operator (consistent with evaluateExpression), not on the resolved value shape. + if (IN.equals(expression.getOperator())) { return evaluateInForNumber((Double) fieldValue.getValue(), resolved.getFieldValue()); } - throw new RuleEvaluationException( - "Symbolic resolver returned unsupported type: " + resolved.getType()); + return operator.apply(fieldValue.getValue(), Double.parseDouble(resolved.getFieldValue())); } private boolean applyOperatorForList(Operator operator, Object fieldValue, Object expressionValue) { diff --git a/components/rule-mgt/org.wso2.carbon.identity.rule.evaluation/src/test/java/org/wso2/carbon/identity/rule/evaluation/core/RuleEvaluatorSymbolicTest.java b/components/rule-mgt/org.wso2.carbon.identity.rule.evaluation/src/test/java/org/wso2/carbon/identity/rule/evaluation/core/RuleEvaluatorSymbolicTest.java new file mode 100644 index 000000000000..42c2508e0d3a --- /dev/null +++ b/components/rule-mgt/org.wso2.carbon.identity.rule.evaluation/src/test/java/org/wso2/carbon/identity/rule/evaluation/core/RuleEvaluatorSymbolicTest.java @@ -0,0 +1,170 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.wso2.carbon.identity.rule.evaluation.core; + +import org.testng.annotations.AfterClass; +import org.testng.annotations.BeforeClass; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; +import org.wso2.carbon.identity.rule.evaluation.api.exception.RuleEvaluationException; +import org.wso2.carbon.identity.rule.evaluation.api.model.FieldValue; +import org.wso2.carbon.identity.rule.evaluation.api.resolver.SymbolicValueResolver; +import org.wso2.carbon.identity.rule.evaluation.api.resolver.SymbolicValueResolverRegistry; +import org.wso2.carbon.identity.rule.evaluation.internal.component.RuleEvaluationComponentServiceHolder; +import org.wso2.carbon.identity.rule.evaluation.internal.service.impl.OperatorRegistry; +import org.wso2.carbon.identity.rule.evaluation.internal.service.impl.RuleEvaluator; +import org.wso2.carbon.identity.rule.management.api.model.ANDCombinedRule; +import org.wso2.carbon.identity.rule.management.api.model.Expression; +import org.wso2.carbon.identity.rule.management.api.model.ORCombinedRule; +import org.wso2.carbon.identity.rule.management.api.model.Rule; +import org.wso2.carbon.identity.rule.management.api.model.Value; +import org.wso2.carbon.identity.rule.metadata.api.model.Operator; +import org.wso2.carbon.identity.rule.metadata.api.service.RuleMetadataService; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertTrue; + +/** + * Unit tests for evaluating rules with symbolic values in RuleEvaluator. + */ +public class RuleEvaluatorSymbolicTest { + + private static final String OS_VERSION = "osVersion"; + + // Test resolver: symbolic tokens resolve to numeric values; special tokens exercise the error paths. + private static final SymbolicValueResolver TEST_RESOLVER = symbolicValue -> { + switch (symbolicValue) { + case "LATEST": + return new Value(Value.Type.NUMBER, "15"); + case "LATEST,PREVIOUS": + return new Value(Value.Type.LIST, "15,14"); + case "BAD_LIST": + return new Value(Value.Type.LIST, "abc,def"); + case "BAD_TYPE": + return new Value(Value.Type.STRING, "x"); + default: + return new Value(Value.Type.NUMBER, symbolicValue); + } + }; + + private RuleEvaluator ruleEvaluator; + + @BeforeClass + public void setUpClass() { + + RuleMetadataService ruleMetadataService = mock(RuleMetadataService.class); + when(ruleMetadataService.getApplicableOperatorsInExpressions()).thenReturn( + Arrays.asList(new Operator("equals", "equals"), new Operator("in", "in"))); + RuleEvaluationComponentServiceHolder.getInstance().setRuleMetadataService(ruleMetadataService); + + SymbolicValueResolverRegistry.getInstance().register(OS_VERSION, TEST_RESOLVER); + } + + @BeforeMethod + public void setUpMethod() { + + OperatorRegistry operatorRegistry = RuleEvaluationComponentServiceHolder.getInstance().getOperatorRegistry(); + ruleEvaluator = new RuleEvaluator(operatorRegistry); + } + + @AfterClass + public void tearDownClass() { + + SymbolicValueResolverRegistry.getInstance().deregister(OS_VERSION); + } + + @Test + public void testSymbolicInWithListMatch() throws Exception { + + assertTrue(ruleEvaluator.evaluate(symbolicRule(OS_VERSION, "in", "LATEST,PREVIOUS"), + numberData(OS_VERSION, 15))); + } + + @Test + public void testSymbolicInWithListNoMatch() throws Exception { + + assertFalse(ruleEvaluator.evaluate(symbolicRule(OS_VERSION, "in", "LATEST,PREVIOUS"), + numberData(OS_VERSION, 99))); + } + + @Test + public void testSymbolicInWithSingleValueMatch() throws Exception { + + // 'in' with a single resolved value (NUMBER) must still do a membership check and match. + assertTrue(ruleEvaluator.evaluate(symbolicRule(OS_VERSION, "in", "LATEST"), + numberData(OS_VERSION, 15))); + } + + @Test + public void testSymbolicEqualsMatch() throws Exception { + + assertTrue(ruleEvaluator.evaluate(symbolicRule(OS_VERSION, "equals", "LATEST"), + numberData(OS_VERSION, 15))); + } + + @Test + public void testSymbolicEqualsNoMatch() throws Exception { + + assertFalse(ruleEvaluator.evaluate(symbolicRule(OS_VERSION, "equals", "LATEST"), + numberData(OS_VERSION, 14))); + } + + @Test(expectedExceptions = RuleEvaluationException.class, + expectedExceptionsMessageRegExp = "Failed to parse numeric value in 'in' expression: abc,def") + public void testSymbolicInWithNonNumericValueThrows() throws Exception { + + ruleEvaluator.evaluate(symbolicRule(OS_VERSION, "in", "BAD_LIST"), numberData(OS_VERSION, 15)); + } + + @Test(expectedExceptions = RuleEvaluationException.class, + expectedExceptionsMessageRegExp = "Symbolic resolver returned unsupported type: STRING") + public void testSymbolicUnsupportedResolvedTypeThrows() throws Exception { + + ruleEvaluator.evaluate(symbolicRule(OS_VERSION, "equals", "BAD_TYPE"), numberData(OS_VERSION, 15)); + } + + @Test(expectedExceptions = RuleEvaluationException.class, + expectedExceptionsMessageRegExp = "No symbolic resolver registered for field: noResolverField") + public void testSymbolicNoResolverRegisteredThrows() throws Exception { + + ruleEvaluator.evaluate(symbolicRule("noResolverField", "equals", "LATEST"), + numberData("noResolverField", 15)); + } + + private Rule symbolicRule(String field, String operator, String symbolicValue) { + + Expression expression = new Expression.Builder().field(field).operator(operator) + .value(new Value(Value.Type.SYMBOLIC, symbolicValue)).build(); + ANDCombinedRule andRule = new ANDCombinedRule.Builder().addExpression(expression).build(); + return new ORCombinedRule.Builder().addRule(andRule).build(); + } + + private Map numberData(String field, double value) { + + Map evaluationData = new HashMap<>(); + evaluationData.put(field, new FieldValue(field, value)); + return evaluationData; + } +} diff --git a/components/rule-mgt/org.wso2.carbon.identity.rule.evaluation/src/test/java/org/wso2/carbon/identity/rule/evaluation/core/SymbolicValueResolverRegistryTest.java b/components/rule-mgt/org.wso2.carbon.identity.rule.evaluation/src/test/java/org/wso2/carbon/identity/rule/evaluation/core/SymbolicValueResolverRegistryTest.java new file mode 100644 index 000000000000..d86d5fa4182c --- /dev/null +++ b/components/rule-mgt/org.wso2.carbon.identity.rule.evaluation/src/test/java/org/wso2/carbon/identity/rule/evaluation/core/SymbolicValueResolverRegistryTest.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (http://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package org.wso2.carbon.identity.rule.evaluation.core; + +import org.testng.annotations.Test; +import org.wso2.carbon.identity.rule.evaluation.api.resolver.SymbolicValueResolver; +import org.wso2.carbon.identity.rule.evaluation.api.resolver.SymbolicValueResolverRegistry; +import org.wso2.carbon.identity.rule.management.api.model.Value; + +import static org.testng.Assert.assertNull; +import static org.testng.Assert.assertSame; + +/** + * Unit tests for SymbolicValueResolverRegistry. + */ +public class SymbolicValueResolverRegistryTest { + + private static final String FIELD = "osVersionTestField"; + + @Test + public void testRegisterAndGetResolver() { + + SymbolicValueResolverRegistry registry = SymbolicValueResolverRegistry.getInstance(); + SymbolicValueResolver resolver = symbolicValue -> new Value(Value.Type.NUMBER, "15"); + + registry.register(FIELD, resolver); + assertSame(registry.getResolver(FIELD), resolver); + + registry.deregister(FIELD); + assertNull(registry.getResolver(FIELD)); + } + + @Test + public void testGetResolverForUnregisteredField() { + + assertNull(SymbolicValueResolverRegistry.getInstance().getResolver("noSuchField")); + } +} diff --git a/components/rule-mgt/org.wso2.carbon.identity.rule.evaluation/src/test/resources/testng.xml b/components/rule-mgt/org.wso2.carbon.identity.rule.evaluation/src/test/resources/testng.xml index a54e6a8392b1..3f5ea24f0268 100644 --- a/components/rule-mgt/org.wso2.carbon.identity.rule.evaluation/src/test/resources/testng.xml +++ b/components/rule-mgt/org.wso2.carbon.identity.rule.evaluation/src/test/resources/testng.xml @@ -24,7 +24,9 @@ + + From 7a2ef2fbecd4e764603cdd7c238fe739e2d9d739 Mon Sep 17 00:00:00 2001 From: kaviska Date: Thu, 9 Jul 2026 11:29:52 +0530 Subject: [PATCH 4/5] Rename evaluateInForNumber parameter to a generic name The rule engine is generic infrastructure, so rename the 'deviceValue' parameter of evaluateInForNumber to 'numericValue' to avoid a device-specific name in the shared component. --- .../rule/evaluation/internal/service/impl/RuleEvaluator.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/components/rule-mgt/org.wso2.carbon.identity.rule.evaluation/src/main/java/org/wso2/carbon/identity/rule/evaluation/internal/service/impl/RuleEvaluator.java b/components/rule-mgt/org.wso2.carbon.identity.rule.evaluation/src/main/java/org/wso2/carbon/identity/rule/evaluation/internal/service/impl/RuleEvaluator.java index 7b8ad2a5dcd2..befcd433e9fc 100644 --- a/components/rule-mgt/org.wso2.carbon.identity.rule.evaluation/src/main/java/org/wso2/carbon/identity/rule/evaluation/internal/service/impl/RuleEvaluator.java +++ b/components/rule-mgt/org.wso2.carbon.identity.rule.evaluation/src/main/java/org/wso2/carbon/identity/rule/evaluation/internal/service/impl/RuleEvaluator.java @@ -146,14 +146,14 @@ private boolean evaluateExpression(Expression expression, Map v.equals(deviceValue)); + .anyMatch(v -> v.equals(numericValue)); } catch (NumberFormatException e) { throw new RuleEvaluationException( "Failed to parse numeric value in 'in' expression: " + commaSeparated, e); From bf08b6242e4e77cd349011e75ac073a6eebfe698 Mon Sep 17 00:00:00 2001 From: kaviska Date: Thu, 9 Jul 2026 12:21:36 +0530 Subject: [PATCH 5/5] Clean up RuleBuilder: remove unused resolveValue overload and simplify ValueType import - Remove the unused 2-arg resolveValue overload (dead code; the single call site always passes the originalValue argument to the 3-arg version) - Import Value.ValueType and use the short name instead of the fully qualified name inline --- .../rule/management/api/util/RuleBuilder.java | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/components/rule-mgt/org.wso2.carbon.identity.rule.management/src/main/java/org/wso2/carbon/identity/rule/management/api/util/RuleBuilder.java b/components/rule-mgt/org.wso2.carbon.identity.rule.management/src/main/java/org/wso2/carbon/identity/rule/management/api/util/RuleBuilder.java index 9efe24fa29bc..6617e6dee6c4 100644 --- a/components/rule-mgt/org.wso2.carbon.identity.rule.management/src/main/java/org/wso2/carbon/identity/rule/management/api/util/RuleBuilder.java +++ b/components/rule-mgt/org.wso2.carbon.identity.rule.management/src/main/java/org/wso2/carbon/identity/rule/management/api/util/RuleBuilder.java @@ -31,6 +31,7 @@ import org.wso2.carbon.identity.rule.metadata.api.exception.RuleMetadataException; import org.wso2.carbon.identity.rule.metadata.api.model.FieldDefinition; import org.wso2.carbon.identity.rule.metadata.api.model.OptionsInputValue; +import org.wso2.carbon.identity.rule.metadata.api.model.Value.ValueType; import java.util.List; import java.util.Map; @@ -253,20 +254,12 @@ private Value validateAndResolveValue(FieldDefinition fieldDefinition, Value val } } - private Value resolveValue(FieldDefinition fieldDefinition, - String rawValue) throws RuleManagementClientException { - - return resolveValue(fieldDefinition, rawValue, null); - } - private Value resolveValue(FieldDefinition fieldDefinition, String rawValue, Value originalValue) throws RuleManagementClientException { - org.wso2.carbon.identity.rule.metadata.api.model.Value.ValueType - fieldDefinitionValueType = fieldDefinition.getValue().getValueType(); + ValueType fieldDefinitionValueType = fieldDefinition.getValue().getValueType(); - boolean isSymbolicField = fieldDefinitionValueType == - org.wso2.carbon.identity.rule.metadata.api.model.Value.ValueType.SYMBOLIC; + boolean isSymbolicField = fieldDefinitionValueType == ValueType.SYMBOLIC; if (originalValue != null && originalValue.getType() == Value.Type.LIST && !isSymbolicField) { return new Value(Value.Type.LIST, rawValue); } @@ -291,8 +284,7 @@ private Value resolveValue(FieldDefinition fieldDefinition, private boolean isValidValueType(FieldDefinition fieldDefinition, Value value) { - org.wso2.carbon.identity.rule.metadata.api.model.Value.ValueType fieldDefinitionValueType = - fieldDefinition.getValue().getValueType(); + ValueType fieldDefinitionValueType = fieldDefinition.getValue().getValueType(); Value.Type valueType = value.getType(); // RAW and LIST bypass strict type matching — RAW is stored as-is, LIST is for multi-value (in) operator.