diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs index a7638871b..4e3540980 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureAppConfigurationProvider.cs @@ -916,7 +916,7 @@ private async Task>> ProcessAdapters(Co continue; } - IEnumerable> kvs = await adapter.ProcessKeyValue(setting, _logger, cancellationToken).ConfigureAwait(false); + IEnumerable> kvs = await adapter.ProcessKeyValue(setting, AppConfigurationEndpoint, _logger, cancellationToken).ConfigureAwait(false); if (kvs != null) { diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureKeyVaultReference/AzureKeyVaultKeyValueAdapter.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureKeyVaultReference/AzureKeyVaultKeyValueAdapter.cs index 21b5165f9..e49deb2cb 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureKeyVaultReference/AzureKeyVaultKeyValueAdapter.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/AzureKeyVaultReference/AzureKeyVaultKeyValueAdapter.cs @@ -27,7 +27,7 @@ public AzureKeyVaultKeyValueAdapter(AzureKeyVaultSecretProvider secretProvider) /// Uses the Azure Key Vault secret provider to resolve Key Vault references retrieved from Azure App Configuration. /// inputs the IKeyValue /// returns the keyname and actual value - public async Task>> ProcessKeyValue(ConfigurationSetting setting, Logger logger, CancellationToken cancellationToken) + public async Task>> ProcessKeyValue(ConfigurationSetting setting, Uri endpoint, Logger logger, CancellationToken cancellationToken) { KeyVaultSecretReference secretRef; diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/BytesExtensions.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/BytesExtensions.cs new file mode 100644 index 000000000..faf20653d --- /dev/null +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/Extensions/BytesExtensions.cs @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +// +using System.Text; +using System; + +namespace Microsoft.Extensions.Configuration.AzureAppConfiguration.Extensions +{ + internal static class BytesExtensions + { + /// + /// Converts a byte array to Base64URL string with optional padding ('=') characters removed. + /// Base64 description: https://datatracker.ietf.org/doc/html/rfc4648.html#section-4 + /// + public static string ToBase64Url(this byte[] bytes) + { + string bytesBase64 = Convert.ToBase64String(bytes); + + int indexOfEquals = bytesBase64.IndexOf("="); + + // Remove all instances of "=" at the end of the string that were added as padding + int stringBuilderCapacity = indexOfEquals != -1 ? indexOfEquals : bytesBase64.Length; + + StringBuilder stringBuilder = new StringBuilder(stringBuilderCapacity); + + // Construct Base64URL string by replacing characters in Base64 conversion that are not URL safe + for (int i = 0; i < stringBuilderCapacity; i++) + { + if (bytesBase64[i] == '+') + { + stringBuilder.Append('-'); + } + else if (bytesBase64[i] == '/') + { + stringBuilder.Append('_'); + } + else + { + stringBuilder.Append(bytesBase64[i]); + } + } + + return stringBuilder.ToString(); + } + } +} diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementConstants.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementConstants.cs index 24c3ed886..578067314 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementConstants.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementConstants.cs @@ -31,5 +31,8 @@ internal class FeatureManagementConstants public const string From = "From"; public const string To = "To"; public const string Seed = "Seed"; + public const string ETag = "ETag"; + public const string FeatureFlagId = "FeatureFlagId"; + public const string FeatureFlagReference = "FeatureFlagReference"; } } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementKeyValueAdapter.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementKeyValueAdapter.cs index 457a3a6ae..e22437ba3 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementKeyValueAdapter.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/FeatureManagement/FeatureManagementKeyValueAdapter.cs @@ -2,9 +2,12 @@ // Licensed under the MIT license. // using Azure.Data.AppConfiguration; +using Microsoft.Extensions.Configuration.AzureAppConfiguration.Extensions; using System; using System.Collections.Generic; using System.Linq; +using System.Security.Cryptography; +using System.Text; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -20,7 +23,7 @@ public FeatureManagementKeyValueAdapter(FeatureFilterTracing featureFilterTracin _featureFilterTracing = featureFilterTracing ?? throw new ArgumentNullException(nameof(featureFilterTracing)); } - public Task>> ProcessKeyValue(ConfigurationSetting setting, Logger logger, CancellationToken cancellationToken) + public Task>> ProcessKeyValue(ConfigurationSetting setting, Uri endpoint, Logger logger, CancellationToken cancellationToken) { FeatureFlag featureFlag; try @@ -197,15 +200,28 @@ public Task>> ProcessKeyValue(Configura if (telemetry.Enabled) { - keyValues.Add(new KeyValuePair($"{telemetryPath}:{FeatureManagementConstants.Enabled}", telemetry.Enabled.ToString())); - } + if (telemetry.Metadata != null) + { + foreach (KeyValuePair kvp in telemetry.Metadata) + { + keyValues.Add(new KeyValuePair($"{telemetryPath}:{FeatureManagementConstants.Metadata}:{kvp.Key}", kvp.Value)); + } + } - if (telemetry.Metadata != null) - { - foreach (KeyValuePair kvp in telemetry.Metadata) + string featureFlagId = CalculateFeatureFlagId(setting.Key, setting.Label); + + keyValues.Add(new KeyValuePair($"{telemetryPath}:{FeatureManagementConstants.Metadata}:{FeatureManagementConstants.FeatureFlagId}", featureFlagId)); + + if (endpoint != null) { - keyValues.Add(new KeyValuePair($"{telemetryPath}:{FeatureManagementConstants.Metadata}:{kvp.Key}", kvp.Value)); + string featureFlagReference = $"{endpoint.AbsoluteUri}kv/{setting.Key}{(!string.IsNullOrWhiteSpace(setting.Label) ? $"?label={setting.Label}" : "")}"; + + keyValues.Add(new KeyValuePair($"{telemetryPath}:{FeatureManagementConstants.Metadata}:{FeatureManagementConstants.FeatureFlagReference}", featureFlagReference)); } + + keyValues.Add(new KeyValuePair($"{telemetryPath}:{FeatureManagementConstants.Metadata}:{FeatureManagementConstants.ETag}", setting.ETag.ToString())); + + keyValues.Add(new KeyValuePair($"{telemetryPath}:{FeatureManagementConstants.Enabled}", telemetry.Enabled.ToString())); } } @@ -229,5 +245,19 @@ public bool NeedsRefresh() { return false; } + + private static string CalculateFeatureFlagId(string key, string label) + { + byte[] featureFlagIdHash; + + // Convert the value consisting of key, newline character, and label to a byte array using UTF8 encoding to hash it using SHA 256 + using (HashAlgorithm hashAlgorithm = SHA256.Create()) + { + featureFlagIdHash = hashAlgorithm.ComputeHash(Encoding.UTF8.GetBytes($"{key}\n{(string.IsNullOrWhiteSpace(label) ? null : label)}")); + } + + // Convert the hashed byte array to Base64Url + return featureFlagIdHash.ToBase64Url(); + } } } diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/IConfigurationRefresher.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/IConfigurationRefresher.cs index b47184a29..78df71256 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/IConfigurationRefresher.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/IConfigurationRefresher.cs @@ -2,7 +2,6 @@ // Licensed under the MIT license. // using Azure; -using Microsoft.Extensions.Logging; using System; using System.Threading; using System.Threading.Tasks; diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/IKeyValueAdapter.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/IKeyValueAdapter.cs index 48fc85c5c..8da0ee55d 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/IKeyValueAdapter.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/IKeyValueAdapter.cs @@ -2,6 +2,7 @@ // Licensed under the MIT license. // using Azure.Data.AppConfiguration; +using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; @@ -10,7 +11,7 @@ namespace Microsoft.Extensions.Configuration.AzureAppConfiguration { internal interface IKeyValueAdapter { - Task>> ProcessKeyValue(ConfigurationSetting setting, Logger logger, CancellationToken cancellationToken); + Task>> ProcessKeyValue(ConfigurationSetting setting, Uri endpoint, Logger logger, CancellationToken cancellationToken); bool CanProcess(ConfigurationSetting setting); diff --git a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/JsonKeyValueAdapter.cs b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/JsonKeyValueAdapter.cs index 0a47923fc..fed06f11b 100644 --- a/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/JsonKeyValueAdapter.cs +++ b/src/Microsoft.Extensions.Configuration.AzureAppConfiguration/JsonKeyValueAdapter.cs @@ -22,7 +22,7 @@ internal class JsonKeyValueAdapter : IKeyValueAdapter KeyVaultConstants.ContentType }; - public Task>> ProcessKeyValue(ConfigurationSetting setting, Logger logger, CancellationToken cancellationToken) + public Task>> ProcessKeyValue(ConfigurationSetting setting, Uri endpoint, Logger logger, CancellationToken cancellationToken) { if (setting == null) { diff --git a/tests/Tests.AzureAppConfiguration/FeatureManagementTests.cs b/tests/Tests.AzureAppConfiguration/FeatureManagementTests.cs index 61707529d..02a3d3c8c 100644 --- a/tests/Tests.AzureAppConfiguration/FeatureManagementTests.cs +++ b/tests/Tests.AzureAppConfiguration/FeatureManagementTests.cs @@ -6,6 +6,7 @@ using Azure.Core.Testing; using Azure.Data.AppConfiguration; using Azure.Data.AppConfiguration.Tests; +using Azure.Identity; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration.AzureAppConfiguration; using Microsoft.Extensions.Configuration.AzureAppConfiguration.FeatureManagement; @@ -14,6 +15,9 @@ using System.Collections.Generic; using System.Diagnostics.Tracing; using System.Linq; +using System.Net; +using System.Security.Cryptography; +using System.Text; using System.Threading; using System.Threading.Tasks; using Xunit; @@ -288,7 +292,7 @@ public class FeatureManagementTests } } ", - label: default, + label: "label", contentType: FeatureManagementConstants.ContentType + ";charset=utf-8", eTag: new ETag("c3c231fd-39a0-4cb6-3237-4614474b92c1")); @@ -1330,6 +1334,7 @@ public void WithTelemetry() .AddAzureAppConfiguration(options => { options.ClientManager = TestHelpers.CreateMockedConfigurationClientManager(mockClient.Object); + options.Connect(TestHelpers.PrimaryConfigStoreEndpoint, new DefaultAzureCredential()); options.UseFeatureFlags(); }) .Build(); @@ -1337,6 +1342,22 @@ public void WithTelemetry() Assert.Equal("True", config["FeatureManagement:TelemetryFeature:Telemetry:Enabled"]); Assert.Equal("Tag1Value", config["FeatureManagement:TelemetryFeature:Telemetry:Metadata:Tags.Tag1"]); Assert.Equal("Tag2Value", config["FeatureManagement:TelemetryFeature:Telemetry:Metadata:Tags.Tag2"]); + Assert.Equal("c3c231fd-39a0-4cb6-3237-4614474b92c1", config["FeatureManagement:TelemetryFeature:Telemetry:Metadata:ETag"]); + + byte[] featureFlagIdHash; + + using (HashAlgorithm hashAlgorithm = SHA256.Create()) + { + featureFlagIdHash = hashAlgorithm.ComputeHash(Encoding.UTF8.GetBytes($"{FeatureManagementConstants.FeatureFlagMarker}TelemetryFeature\nlabel")); + } + + string featureFlagId = Convert.ToBase64String(featureFlagIdHash) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + + Assert.Equal(featureFlagId, config["FeatureManagement:TelemetryFeature:Telemetry:Metadata:FeatureFlagId"]); + Assert.Equal($"{TestHelpers.PrimaryConfigStoreEndpoint}kv/{FeatureManagementConstants.FeatureFlagMarker}TelemetryFeature?label=label", config["FeatureManagement:TelemetryFeature:Telemetry:Metadata:FeatureFlagReference"]); } diff --git a/tests/Tests.AzureAppConfiguration/KeyVaultReferenceTests.cs b/tests/Tests.AzureAppConfiguration/KeyVaultReferenceTests.cs index 0cccf3cd2..9a80a350e 100644 --- a/tests/Tests.AzureAppConfiguration/KeyVaultReferenceTests.cs +++ b/tests/Tests.AzureAppConfiguration/KeyVaultReferenceTests.cs @@ -422,7 +422,7 @@ public void DoesNotThrowKeyVaultExceptionWhenProviderIsOptional() var mockKeyValueAdapter = new Mock(MockBehavior.Strict); mockKeyValueAdapter.Setup(adapter => adapter.CanProcess(_kv)) .Returns(true); - mockKeyValueAdapter.Setup(adapter => adapter.ProcessKeyValue(_kv, It.IsAny(), It.IsAny())) + mockKeyValueAdapter.Setup(adapter => adapter.ProcessKeyValue(_kv, It.IsAny(), It.IsAny(), It.IsAny())) .Throws(new KeyVaultReferenceException("Key vault error", null)); mockKeyValueAdapter.Setup(adapter => adapter.InvalidateCache(null));