From 82c87b1ad65ed4fad1190ae1c56ce4768fc28eb1 Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Wed, 17 May 2023 10:40:31 -0700 Subject: [PATCH 1/2] Feature Management Deny List --- .../CHANGELOG.md | 25 ++++++ .../README.md | 7 +- .../management/filters/TargetingFilter.java | 60 ++++++++++---- .../implementation/targeting/Audience.java | 19 +++++ .../implementation/targeting/Exclusion.java | 45 ++++++++++ .../FeatureFilterEvaluationContext.java | 7 +- .../filters/TargetingFilterTest.java | 82 +++++++++++++++++-- 7 files changed, 220 insertions(+), 25 deletions(-) create mode 100644 sdk/spring/spring-cloud-azure-feature-management/src/main/java/com/azure/spring/cloud/feature/management/implementation/targeting/Exclusion.java diff --git a/sdk/spring/spring-cloud-azure-feature-management/CHANGELOG.md b/sdk/spring/spring-cloud-azure-feature-management/CHANGELOG.md index cb9ba368e8f4..58fbaac3cae1 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..c7be986db748 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. */ @@ -111,26 +117,45 @@ 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))) { + // Need to Check denied first + if (targetUser(targetingContext.getUserId(), audience.getExclusion().getUsers())) { + return false; + } + + if (targetingContext.getGroups() != null && audience.getExclusion().getGroups() != null) { + for (String group : targetingContext.getGroups()) { + Optional groupRollout = audience.getExclusion().getGroups().stream() + .filter(g -> equals(g, group)).findFirst(); + if (groupRollout.isPresent()) { + return false; + } + } + } + + // Check if Allowed + if (targetUser(targetingContext.getUserId(), audience.getUsers())) { return true; } @@ -153,6 +178,10 @@ 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 validateTargetingContext(TargetingFilterContext targetingContext) { boolean hasUserDefined = StringUtils.hasText(targetingContext.getUserId()); @@ -213,8 +242,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; @@ -259,7 +287,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 4dd857659262..b570fe2cd0cb 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 @@ -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,13 +299,13 @@ 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); @@ -315,8 +313,78 @@ public void targetedNull() { TargetingFilter filter = new TargetingFilter(new TargetingFilterTestContextAccessor("doe", null), options); - Exception exception = assertThrows(TargetingException.class, () -> filter.evaluate(context)); - assertEquals("Audience : " + REQUIRED_PARAMETER, exception.getMessage()); + assertFalse(filter.evaluate(context)); + } + + 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 { From cbe16bf539145a0e71f761f979cfeb340706fda5 Mon Sep 17 00:00:00 2001 From: Matt Metcalf Date: Thu, 18 May 2023 10:58:02 -0700 Subject: [PATCH 2/2] Making TargetGroup a method --- .../management/filters/TargetingFilter.java | 26 ++++++++++++------- 1 file changed, 17 insertions(+), 9 deletions(-) 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 c7be986db748..f88ec00d56c3 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 @@ -161,15 +161,8 @@ public boolean evaluate(FeatureFilterEvaluationContext context) { if (targetingContext.getGroups() != null && audience.getGroups() != null) { for (String group : targetingContext.getGroups()) { - 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; - } + if (targetGroup(audience, targetingContext, context, group)) { + return true; } } } @@ -182,6 +175,21 @@ public boolean evaluate(FeatureFilterEvaluationContext context) { 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());