diff --git a/sdk/spring/spring-cloud-azure-feature-management/CHANGELOG.md b/sdk/spring/spring-cloud-azure-feature-management/CHANGELOG.md index 614a11d3ebe7..6f809e5a7e74 100644 --- a/sdk/spring/spring-cloud-azure-feature-management/CHANGELOG.md +++ b/sdk/spring/spring-cloud-azure-feature-management/CHANGELOG.md @@ -4,6 +4,31 @@ ### Features Added +* Added Deny List for Targeting Filter. + +```yml +feature-management: + TargetingTest: + enabled-for: + - + name: Microsoft.Targeting + parameters: + users: + - Jeff + - Alicia + groups: + - + name: Ring0 + rolloutPercentage: 100 + - + name: Ring1 + rolloutPercentage: 100 + defaultRolloutPercentage: 50 + exclusion: + users: + - Ross +``` + ### Breaking Changes ### Bugs Fixed diff --git a/sdk/spring/spring-cloud-azure-feature-management/README.md b/sdk/spring/spring-cloud-azure-feature-management/README.md index d8020de01359..127001dd28d2 100644 --- a/sdk/spring/spring-cloud-azure-feature-management/README.md +++ b/sdk/spring/spring-cloud-azure-feature-management/README.md @@ -198,7 +198,7 @@ feature-management: ### TargetingFilter -This filter provides the capability to enable a feature for a target audience. An in-depth explanation of targeting is explained in the targeting section below. The filter parameters include an audience object which describes users, groups, and a default percentage of the user base that should have access to the feature. Each group object that is listed in the target audience must also specify what percentage of the group's members should have access. If a user is specified in the users section directly, or if the user is in the included percentage of any of the group rollouts, or if the user falls into the default rollout percentage then that user will have the feature enabled. +This filter provides the capability to enable a feature for a target audience. An in-depth explanation of targeting is explained in the targeting section below. The filter parameters include an audience object which describes users, groups, and a default percentage of the user base that should have access to the feature, and an exclusion object for users and groups that should never be targeted. Each group object that is listed in the target audience must also specify what percentage of the group's members should have access. If a user is specified in the users section directly, or if the user is in the included percentage of any of the group rollouts, or if the user falls into the default rollout percentage then that user will have the feature enabled. ```yml feature-management: @@ -219,6 +219,11 @@ feature-management: name: Ring1 rolloutPercentage: 100 defaultRolloutPercentage: 50 + exclusion: + users: + - Ross + groups: + - Ring2 ``` ## Targeting diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/filters/TargetingFilter.java b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/filters/TargetingFilter.java index eeb6807743cd..f11fbceef1ee 100644 --- a/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/filters/TargetingFilter.java +++ b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/filters/TargetingFilter.java @@ -6,10 +6,11 @@ import java.nio.charset.Charset; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; +import java.util.Collection; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; -import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -49,6 +50,11 @@ public class TargetingFilter implements FeatureFilter { */ protected static final String AUDIENCE = "Audience"; + /** + * Audience that always returns false + */ + protected static final String EXCLUSION = "exclusion"; + /** * Error message for when the total Audience value is greater than 100 percent. */ @@ -74,6 +80,7 @@ public class TargetingFilter implements FeatureFilter { /** * Filter for targeting a user/group/percentage of users. + * * @param contextAccessor Accessor for identifying the current user/group when evaluating */ public TargetingFilter(TargetingContextAccessor contextAccessor) { @@ -83,6 +90,7 @@ public TargetingFilter(TargetingContextAccessor contextAccessor) { /** * `Microsoft.TargetingFilter` evaluates a user/group/overall rollout of a feature. + * * @param contextAccessor Context for evaluating the users/groups. * @param options enables customization of the filter. */ @@ -111,40 +119,52 @@ public boolean evaluate(FeatureFilterEvaluationContext context) { Map parameters = context.getParameters(); - if (parameters != null) { - Object audienceObject = parameters.get(AUDIENCE); - if (audienceObject != null) { - parameters = (Map) audienceObject; - } + Object audienceObject = parameters.get(AUDIENCE); + if (audienceObject != null) { + parameters = (Map) audienceObject; + } - updateValueFromMapToList(parameters, USERS); - updateValueFromMapToList(parameters, GROUPS); + updateValueFromMapToList(parameters, USERS); + updateValueFromMapToList(parameters, GROUPS); - settings.setAudience(OBJECT_MAPPER.convertValue(parameters, Audience.class)); + Map> exclusionMap = (Map>) parameters + .get(EXCLUSION); + Map exclusionAsLists = new HashMap<>(); + if (exclusionMap != null) { + exclusionAsLists.put(USERS, exclusionMap.getOrDefault(USERS, new HashMap()).values()); + exclusionAsLists.put(GROUPS, exclusionMap.getOrDefault(GROUPS, new HashMap()).values()); } + parameters.put(EXCLUSION, exclusionAsLists); + settings.setAudience(OBJECT_MAPPER.convertValue(parameters, Audience.class)); validateSettings(settings); Audience audience = settings.getAudience(); - if (targetingContext.getUserId() != null - && audience.getUsers() != null - && audience.getUsers().stream() - .anyMatch(user -> equals(targetingContext.getUserId(), user))) { - return true; + // Need to Check denied first + if (targetUser(targetingContext.getUserId(), audience.getExclusion().getUsers())) { + return false; } - if (targetingContext.getGroups() != null && audience.getGroups() != null) { + if (targetingContext.getGroups() != null && audience.getExclusion().getGroups() != null) { for (String group : targetingContext.getGroups()) { - Optional groupRollout = audience.getGroups().stream() - .filter(g -> equals(g.getName(), group)).findFirst(); - + Optional groupRollout = audience.getExclusion().getGroups().stream() + .filter(g -> equals(g, group)).findFirst(); if (groupRollout.isPresent()) { - String audienceContextId = targetingContext.getUserId() + "\n" + context.getName() + "\n" + group; + return false; + } + } + } - if (isTargeted(audienceContextId, groupRollout.get().getRolloutPercentage())) { - return true; - } + // Check if Allowed + if (targetUser(targetingContext.getUserId(), audience.getUsers())) { + return true; + } + + if (targetingContext.getGroups() != null && audience.getGroups() != null) { + for (String group : targetingContext.getGroups()) { + if (targetGroup(audience, targetingContext, context, group)) { + return true; } } } @@ -154,6 +174,25 @@ public boolean evaluate(FeatureFilterEvaluationContext context) { return isTargeted(defaultContextId, settings.getAudience().getDefaultRolloutPercentage()); } + private boolean targetUser(String userId, List users) { + return userId != null && users != null && users.stream().anyMatch(user -> equals(userId, user)); + } + + private boolean targetGroup(Audience audience, TargetingFilterContext targetingContext, + FeatureFilterEvaluationContext context, String group) { + Optional groupRollout = audience.getGroups().stream() + .filter(g -> equals(g.getName(), group)).findFirst(); + + if (groupRollout.isPresent()) { + String audienceContextId = targetingContext.getUserId() + "\n" + context.getName() + "\n" + group; + + if (isTargeted(audienceContextId, groupRollout.get().getRolloutPercentage())) { + return true; + } + } + return false; + } + private boolean validateTargetingContext(TargetingFilterContext targetingContext) { boolean hasUserDefined = StringUtils.hasText(targetingContext.getUserId()); boolean hasGroupsDefined = targetingContext.getGroups() != null; @@ -168,6 +207,7 @@ private boolean validateTargetingContext(TargetingFilterContext targetingContext /** * Computes the percentage that the contextId falls into. + * * @param contextId Id of the context being targeted * @return the bucket value of the context id * @throws TargetingException Unable to create hash of target context @@ -198,6 +238,7 @@ private boolean isTargeted(String contextId, double percentage) { /** * Validates the settings of a targeting filter. + * * @param settings targeting filter settings * @throws TargetingException when a required parameter is missing or percentage value is greater than 100. */ @@ -213,8 +254,7 @@ void validateSettings(TargetingFilterSettings settings) { } Audience audience = settings.getAudience(); - if (audience.getDefaultRolloutPercentage() < 0 - || audience.getDefaultRolloutPercentage() > 100) { + if (audience.getDefaultRolloutPercentage() < 0 || audience.getDefaultRolloutPercentage() > 100) { paramName = AUDIENCE + "." + audience.getDefaultRolloutPercentage(); reason = OUT_OF_RANGE; @@ -237,6 +277,7 @@ void validateSettings(TargetingFilterSettings settings) { /** * Checks if two strings are equal, ignores case if configured to. + * * @param s1 string to compare * @param s2 string to compare * @return true if the strings are equal @@ -251,6 +292,7 @@ private boolean equals(String s1, String s2) { /** * Looks at the given key in the parameters and coverts it to a list if it is currently a map. Used for updating * fields in the targeting filter. + * * @param Type of object inside of parameters for the given key * @param parameters map of generic objects * @param key key of object int the parameters map @@ -259,7 +301,7 @@ private boolean equals(String s1, String s2) { private void updateValueFromMapToList(Map parameters, String key) { Object objectMap = parameters.get(key); if (objectMap instanceof Map) { - List toType = ((Map) objectMap).values().stream().collect(Collectors.toList()); + Collection toType = ((Map) objectMap).values(); parameters.put(key, toType); } } diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/implementation/targeting/Audience.java b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/implementation/targeting/Audience.java index bf0d4c7be236..517f2a21de57 100644 --- a/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/implementation/targeting/Audience.java +++ b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/implementation/targeting/Audience.java @@ -4,9 +4,12 @@ import java.util.List; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + /** * Audience of a TargetingFilter rollout */ +@JsonIgnoreProperties(ignoreUnknown = true) public class Audience { private List users; @@ -15,6 +18,8 @@ public class Audience { private double defaultRolloutPercentage; + private Exclusion exclusion = new Exclusion(); + /** * @return the users */ @@ -57,4 +62,18 @@ public void setDefaultRolloutPercentage(double defaultRolloutPercentage) { this.defaultRolloutPercentage = defaultRolloutPercentage; } + /** + * @return the exclusion + */ + public Exclusion getExclusion() { + return exclusion; + } + + /** + * @param exclusion the exclusion to set + */ + public void setExclusion(Exclusion exclusion) { + this.exclusion = exclusion; + } + } diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/implementation/targeting/Exclusion.java b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/implementation/targeting/Exclusion.java new file mode 100644 index 000000000000..331fe636f2b1 --- /dev/null +++ b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/implementation/targeting/Exclusion.java @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. +package com.azure.spring.cloud.feature.management.implementation.targeting; + +import java.util.ArrayList; +import java.util.List; + +/** + * Deny list of a TargetingFilter rollout + */ +public class Exclusion { + + private List users = new ArrayList<>(); + + private List groups = new ArrayList<>(); + + /** + * @return the users + */ + public List getUsers() { + return users; + } + + /** + * @param users the users to set + */ + public void setUsers(List users) { + this.users = users; + } + + /** + * @return the groups + */ + public List getGroups() { + return groups; + } + + /** + * @param groups the audiences to set + */ + public void setGroups(List groups) { + this.groups = groups; + } + +} diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/models/FeatureFilterEvaluationContext.java b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/models/FeatureFilterEvaluationContext.java index 91c4e8d3cae2..623785e621b0 100644 --- a/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/models/FeatureFilterEvaluationContext.java +++ b/sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/models/FeatureFilterEvaluationContext.java @@ -2,6 +2,7 @@ // Licensed under the MIT License. package com.azure.spring.cloud.feature.management.models; +import java.util.HashMap; import java.util.Map; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; @@ -38,7 +39,11 @@ public void setName(String name) { * @return the parameters */ public Map getParameters() { - return parameters; + Map params = new HashMap(); + if (parameters != null) { + params.putAll(parameters); + } + return params; } /** diff --git a/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/filters/TargetingFilterTest.java b/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/filters/TargetingFilterTest.java index b6d000136853..0acad22e1027 100644 --- a/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/filters/TargetingFilterTest.java +++ b/sdk/spring/spring-cloud-azure-feature-management/src/test/java/com/azure/spring/cloud/feature/management/filters/TargetingFilterTest.java @@ -21,7 +21,7 @@ import com.azure.spring.cloud.feature.management.targeting.TargetingContextAccessor; import com.azure.spring.cloud.feature.management.targeting.TargetingEvaluationOptions; -@SpringBootTest(classes = {TestConfiguration.class, SpringBootTest.class}) +@SpringBootTest(classes = { TestConfiguration.class, SpringBootTest.class }) public class TargetingFilterTest { private static final String USERS = "users"; @@ -32,8 +32,6 @@ public class TargetingFilterTest { private static final String DEFAULT_ROLLOUT_PERCENTAGE = "defaultRolloutPercentage"; - private static final String REQUIRED_PARAMETER = "Value cannot be null."; - private static final String OUT_OF_RANGE = "The value is out of the accepted range."; @Test @@ -301,22 +299,92 @@ public void targetedNull() { FeatureFilterEvaluationContext context = new FeatureFilterEvaluationContext(); - Map parameters = new LinkedHashMap(); + Map parameters = new LinkedHashMap<>(); - Map users = new LinkedHashMap(); + Map users = new LinkedHashMap<>(); users.put("0", "Doe"); parameters.put(USERS, users); - parameters.put(GROUPS, new LinkedHashMap()); + parameters.put(GROUPS, new LinkedHashMap<>()); parameters.put(DEFAULT_ROLLOUT_PERCENTAGE, 0); context.setParameters(null); context.setFeatureName("testFeature"); TargetingFilter filter = new TargetingFilter(new TargetingFilterTestContextAccessor("doe", null), options); + assertFalse(filter.evaluate(context)); + } - Exception exception = assertThrows(TargetingException.class, () -> filter.evaluate(context)); - assertEquals("Audience : " + REQUIRED_PARAMETER, exception.getMessage()); + @Test + public void excludeUser() { + FeatureFilterEvaluationContext context = new FeatureFilterEvaluationContext(); + + Map parameters = new LinkedHashMap<>(); + + Map users = new LinkedHashMap<>(); + users.put("0", "Doe"); + + parameters.put(USERS, users); + parameters.put(GROUPS, new LinkedHashMap<>()); + parameters.put(DEFAULT_ROLLOUT_PERCENTAGE, 0); + + context.setParameters(parameters); + context.setFeatureName("testFeature"); + + TargetingFilter filter = new TargetingFilter(new TargetingFilterTestContextAccessor("Doe", null)); + + assertTrue(filter.evaluate(context)); + + // Now the users is excluded + Map excludes = new LinkedHashMap<>(); + Map excludedUsers = new LinkedHashMap<>(); + excludedUsers.put("0", "Doe"); + + excludes.put(USERS, excludedUsers); + parameters.put("exclusion", excludes); + + context.setParameters(parameters); + + assertFalse(filter.evaluate(context)); + } + + @Test + public void excludeGroup() { + FeatureFilterEvaluationContext context = new FeatureFilterEvaluationContext(); + + Map parameters = new LinkedHashMap(); + + Map groups = new LinkedHashMap(); + Map g1 = new LinkedHashMap(); + g1.put("name", "g1"); + g1.put("rolloutPercentage", "100"); + groups.put("0", g1); + + parameters.put(USERS, new LinkedHashMap()); + parameters.put(GROUPS, groups); + parameters.put(DEFAULT_ROLLOUT_PERCENTAGE, 0); + + context.setParameters(parameters); + context.setFeatureName("testFeature"); + + ArrayList targetedGroups = new ArrayList(); + targetedGroups.add("g1"); + + TargetingFilter filter = new TargetingFilter(new TargetingFilterTestContextAccessor(null, targetedGroups)); + + assertTrue(filter.evaluate(context)); + + // Now the users is excluded + Map excludes = new LinkedHashMap<>(); + Map excludedGroups = new LinkedHashMap<>(); + excludedGroups.put("0", "g1"); + + excludes.put(GROUPS, excludedGroups); + parameters.put("exclusion", excludes); + + context.setParameters(parameters); + + assertFalse(filter.evaluate(context)); } class TargetingFilterTestContextAccessor implements TargetingContextAccessor {