diff --git a/examples/TargetingConsoleApp/Identity/InMemoryUserRepository.cs b/examples/TargetingConsoleApp/Identity/InMemoryUserRepository.cs index 3a444c4c..ad8874cd 100644 --- a/examples/TargetingConsoleApp/Identity/InMemoryUserRepository.cs +++ b/examples/TargetingConsoleApp/Identity/InMemoryUserRepository.cs @@ -74,6 +74,19 @@ class InMemoryUserRepository : IUserRepository "TeamMembers" } }, + new User + { + Id = "Anne", + Groups = Enumerable.Empty() + }, + new User + { + Id = "Chuck", + Groups = new List() + { + "Contractor" + } + }, }; public Task GetUser(string id) diff --git a/examples/TargetingConsoleApp/appsettings.json b/examples/TargetingConsoleApp/appsettings.json index a5e827d6..b13d8c26 100644 --- a/examples/TargetingConsoleApp/appsettings.json +++ b/examples/TargetingConsoleApp/appsettings.json @@ -7,7 +7,8 @@ "Parameters": { "Audience": { "Users": [ - "Jeff" + "Jeff", + "Anne" ], "Groups": [ { @@ -19,7 +20,16 @@ "RolloutPercentage": 45 } ], - "DefaultRolloutPercentage": 20 + "DefaultRolloutPercentage": 20, + "Exclusion": { + "Users": [ + "Anne", + "Phil" + ], + "Groups": [ + "Contractor" + ] + } } } } diff --git a/src/Microsoft.FeatureManagement/Targeting/Audience.cs b/src/Microsoft.FeatureManagement/Targeting/Audience.cs index 6a850f65..b27f5e8b 100644 --- a/src/Microsoft.FeatureManagement/Targeting/Audience.cs +++ b/src/Microsoft.FeatureManagement/Targeting/Audience.cs @@ -24,5 +24,10 @@ public class Audience /// Includes users in the audience based off a percentage of the total possible audience. Valid values range from 0 to 100 inclusive. /// public double DefaultRolloutPercentage { get; set; } + + /// + /// Excludes a basic audience from this audience. + /// + public BasicAudience Exclusion { get; set; } } } diff --git a/src/Microsoft.FeatureManagement/Targeting/BasicAudience.cs b/src/Microsoft.FeatureManagement/Targeting/BasicAudience.cs new file mode 100644 index 00000000..26f6023a --- /dev/null +++ b/src/Microsoft.FeatureManagement/Targeting/BasicAudience.cs @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using System.Collections.Generic; + +namespace Microsoft.FeatureManagement.FeatureFilters +{ + /// + /// A basic audience definition describing a set of users and groups. + /// + public class BasicAudience + { + /// + /// Includes users in the audience by name. + /// + public List Users { get; set; } + + /// + /// Includes users in the audience by group name. + /// + public List Groups { get; set; } + } +} diff --git a/src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFilter.cs b/src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFilter.cs index a2d4404e..bfc8907c 100644 --- a/src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFilter.cs +++ b/src/Microsoft.FeatureManagement/Targeting/ContextualTargetingFilter.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using System; +using System.Collections.Generic; using System.Linq; using System.Security.Cryptography; using System.Text; @@ -34,6 +35,7 @@ public ContextualTargetingFilter(IOptions options, I } private StringComparison ComparisonType => _options.IgnoreCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + private StringComparer ComparerType => _options.IgnoreCase ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; /// /// Performs a targeting evaluation using the provided to determine if a feature should be enabled. @@ -61,6 +63,27 @@ public Task EvaluateAsync(FeatureFilterEvaluationContext context, ITargeti throw new ArgumentException(message, paramName); } + if (settings.Audience.Exclusion != null) + { + // + // Check if the user is in the exclusion directly + if (targetingContext.UserId != null && + settings.Audience.Exclusion.Users != null && + settings.Audience.Exclusion.Users.Any(user => targetingContext.UserId.Equals(user, ComparisonType))) + { + return Task.FromResult(false); + } + + // + // Check if the user is in a group within exclusion + if (targetingContext.Groups != null && + settings.Audience.Exclusion.Groups != null && + settings.Audience.Exclusion.Groups.Any(group => targetingContext.Groups.Contains(group, ComparerType))) + { + return Task.FromResult(false); + } + } + // // Check if the user is being targeted directly if (targetingContext.UserId != null && diff --git a/tests/Tests.FeatureManagement/FeatureManagement.cs b/tests/Tests.FeatureManagement/FeatureManagement.cs index b2486b93..5c0fad58 100644 --- a/tests/Tests.FeatureManagement/FeatureManagement.cs +++ b/tests/Tests.FeatureManagement/FeatureManagement.cs @@ -669,6 +669,99 @@ public async Task ThreadsafeSnapshot() } } + [Fact] + public async Task TargetingExclusion() + { + IConfiguration config = new ConfigurationBuilder().AddJsonFile("appsettings.json").Build(); + + var services = new ServiceCollection(); + + services + .Configure(options => + { + options.IgnoreMissingFeatureFilters = true; + }); + + services + .AddSingleton(config) + .AddFeatureManagement() + .AddFeatureFilter(); + + ServiceProvider serviceProvider = services.BuildServiceProvider(); + + IFeatureManager featureManager = serviceProvider.GetRequiredService(); + + string targetingTestFeature = Enum.GetName(typeof(Features), Features.TargetingTestFeatureWithExclusion); + + // + // Targeted by user id + Assert.True(await featureManager.IsEnabledAsync(targetingTestFeature, new TargetingContext + { + UserId = "Alicia" + })); + + // + // Not targeted by user id, but targeted by default rollout + Assert.True(await featureManager.IsEnabledAsync(targetingTestFeature, new TargetingContext + { + UserId = "Anne" + })); + + // + // Not targeted by user id or default rollout + Assert.False(await featureManager.IsEnabledAsync(targetingTestFeature, new TargetingContext + { + UserId = "Patty" + })); + + // + // Targeted by group rollout + Assert.True(await featureManager.IsEnabledAsync(targetingTestFeature, new TargetingContext + { + UserId = "Patty", + Groups = new List() { "Ring1" } + })); + + // + // Not targeted by user id, default rollout or group rollout + Assert.False(await featureManager.IsEnabledAsync(targetingTestFeature, new TargetingContext + { + UserId = "Isaac", + Groups = new List() { "Ring1" } + })); + + // + // Excluded by user id + Assert.False(await featureManager.IsEnabledAsync(targetingTestFeature, new TargetingContext + { + UserId = "Jeff" + })); + + // + // Excluded by group + Assert.False(await featureManager.IsEnabledAsync(targetingTestFeature, new TargetingContext + { + UserId = "Patty", + Groups = new List() { "Ring0" } + })); + + // + // Included and Excluded by group + Assert.False(await featureManager.IsEnabledAsync(targetingTestFeature, new TargetingContext + { + UserId = "Patty", + Groups = new List() { "Ring0", "Ring1" } + })); + + // + // Included user but Excluded by group + Assert.False(await featureManager.IsEnabledAsync(targetingTestFeature, new TargetingContext + { + UserId = "Alicia", + Groups = new List() { "Ring2" } + })); + } + private static void DisableEndpointRouting(MvcOptions options) { #if NET6_0 || NET5_0 || NETCOREAPP3_1 diff --git a/tests/Tests.FeatureManagement/Features.cs b/tests/Tests.FeatureManagement/Features.cs index ade2d13d..c097556d 100644 --- a/tests/Tests.FeatureManagement/Features.cs +++ b/tests/Tests.FeatureManagement/Features.cs @@ -6,6 +6,7 @@ namespace Tests.FeatureManagement enum Features { TargetingTestFeature, + TargetingTestFeatureWithExclusion, OnTestFeature, OffTestFeature, ConditionalFeature, diff --git a/tests/Tests.FeatureManagement/appsettings.json b/tests/Tests.FeatureManagement/appsettings.json index f033a400..5ff4b245 100644 --- a/tests/Tests.FeatureManagement/appsettings.json +++ b/tests/Tests.FeatureManagement/appsettings.json @@ -35,6 +35,41 @@ } ] }, + "TargetingTestFeatureWithExclusion": { + "EnabledFor": [ + { + "Name": "Targeting", + "Parameters": { + "Audience": { + "Users": [ + "Jeff", + "Alicia" + ], + "Groups": [ + { + "Name": "Ring0", + "RolloutPercentage": 100 + }, + { + "Name": "Ring1", + "RolloutPercentage": 50 + } + ], + "DefaultRolloutPercentage": 20, + "Exclusion": { + "Users": [ + "Jeff" + ], + "Groups": [ + "Ring0", + "Ring2" + ] + } + } + } + } + ] + }, "ConditionalFeature": { "EnabledFor": [ {