diff --git a/src/Libraries/Microsoft.Extensions.Compliance.Abstractions/Classification/DataClassification.cs b/src/Libraries/Microsoft.Extensions.Compliance.Abstractions/Classification/DataClassification.cs index dc8fc03273b..d78023d9da1 100644 --- a/src/Libraries/Microsoft.Extensions.Compliance.Abstractions/Classification/DataClassification.cs +++ b/src/Libraries/Microsoft.Extensions.Compliance.Abstractions/Classification/DataClassification.cs @@ -2,6 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; +using System.ComponentModel; using Microsoft.Shared.Diagnostics; namespace Microsoft.Extensions.Compliance.Classification; @@ -9,6 +10,7 @@ namespace Microsoft.Extensions.Compliance.Classification; /// /// Represents a single classification which is part of a data taxonomy. /// +[TypeConverter(typeof(DataClassificationTypeConverter))] public readonly struct DataClassification : IEquatable { /// diff --git a/src/Libraries/Microsoft.Extensions.Compliance.Abstractions/Classification/DataClassificationTypeConverter.cs b/src/Libraries/Microsoft.Extensions.Compliance.Abstractions/Classification/DataClassificationTypeConverter.cs new file mode 100644 index 00000000000..eed6de8724f --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.Compliance.Abstractions/Classification/DataClassificationTypeConverter.cs @@ -0,0 +1,108 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Extensions.Compliance.Classification; + +/// +/// Provides a way to convert a to and from a string. +/// +[Experimental(DiagnosticIds.Experiments.Compliance, UrlFormat = DiagnosticIds.UrlFormat)] +public class DataClassificationTypeConverter : TypeConverter +{ + private const char Delimiter = ':'; + + /// + public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType) + { + return sourceType == typeof(string); + } + + /// + public override bool CanConvertTo(ITypeDescriptorContext? context, Type? destinationType) + { + return destinationType == typeof(DataClassification); + } + + /// + [SuppressMessage("Performance", + "LA0001:Use the 'Microsoft.Shared.Diagnostics.Throws' class instead of explicitly throwing exception for improved performance", + Justification = "Using the Throws class causes static analysis to incorrectly assume that code after the throw is reachable.")] + public override object? ConvertFrom(ITypeDescriptorContext? context, CultureInfo? culture, object value) + { + if (value is not string stringValue) + { + throw new ArgumentException("Value must be a string.", nameof(value)); + } + + if (stringValue == nameof(DataClassification.None)) + { + return DataClassification.None; + } + + if (stringValue == nameof(DataClassification.Unknown)) + { + return DataClassification.Unknown; + } + + if (TryParse(stringValue, out var taxonomyName, out var taxonomyValue)) + { + return new DataClassification(taxonomyName, taxonomyValue); + } + + throw new FormatException($"Invalid data classification format: '{stringValue}'."); + } + + /// + public override bool IsValid(ITypeDescriptorContext? context, object? value) + { + if (value is not string stringValue) + { + return false; + } + + if (stringValue == nameof(DataClassification.None) || + stringValue == nameof(DataClassification.Unknown)) + { + return true; + } + + return TryParse(stringValue, out var taxonomyName, out var taxonomyValue); + } + + /// + /// Attempts to parse a string in the format "TaxonomyName:Value". + /// + /// The input string to parse. + /// When this method returns, contains the parsed taxonomy name if the parsing succeeded, or an empty string if it failed. + /// When this method returns, contains the parsed taxonomy value if the parsing succeeded, or the original input string if it failed. + /// if the string was successfully parsed; otherwise, . + private static bool TryParse(string value, out string taxonomyName, out string taxonomyValue) + { + taxonomyName = string.Empty; + taxonomyValue = value; + + if (value.Length <= 1) + { + return false; + } + + ReadOnlySpan valueSpan = value.AsSpan(); + int index = valueSpan.IndexOf(Delimiter); + + if (index <= 0 || index >= (value.Length - 1)) + { + return false; + } + + taxonomyName = valueSpan.Slice(0, index).ToString(); + taxonomyValue = valueSpan.Slice(index + 1).ToString(); + + return true; + } +} diff --git a/src/Libraries/Microsoft.Extensions.Compliance.Abstractions/README.md b/src/Libraries/Microsoft.Extensions.Compliance.Abstractions/README.md index 8a3d7cba4b4..83287b8918e 100644 --- a/src/Libraries/Microsoft.Extensions.Compliance.Abstractions/README.md +++ b/src/Libraries/Microsoft.Extensions.Compliance.Abstractions/README.md @@ -20,6 +20,90 @@ Or directly in the C# project file: ## Usage Example +### Data Classification + +The `DataClassification` structure encapsulates a classification label within a specific taxonomy for your data. It allows you to mark sensitive information and enforce policies based on classifications. + +- **Taxonomy Name:** Identifies the classification system. +- **Value:** Represents the specific label within the taxonomy. + +#### Creating Custom Classifications + +You can define custom classifications by creating static members that represent different types of sensitive data. This provides a consistent way to label and handle data across your application. + +Example: +```csharp +using Microsoft.Extensions.Compliance.Classification; + +public static class MyTaxonomyClassifications +{ + public static string Name => "MyTaxonomy"; + + public static DataClassification PrivateInformation => new DataClassification(Name, nameof(PrivateInformation)); + public static DataClassification CreditCardNumber => new DataClassification(Name, nameof(CreditCardNumber)); + public static DataClassification SocialSecurityNumber => new DataClassification(Name, nameof(SocialSecurityNumber)); +} +``` + +#### Binding Data Classification Settings + +You can bind data classification settings directly from your configuration using the options pattern. For example: + +appsettings.json +```json +{ + "Key": { + "PhoneNumber": "MyTaxonomy:PrivateInformation", + "ExampleDictionary": { + "CreditCard": "MyTaxonomy:CreditCardNumber", + "SSN": "MyTaxonomy:SocialSecurityNumber", + } + } +} +``` + +```csharp +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Compliance.Classification; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Options; + +public class TestOptions +{ + public DataClassification? PhoneNumber { get; set; } + public IDictionary ExampleDictionary { get; set; } = new Dictionary(); +} + +class Program +{ + static void Main(string[] args) + { + // Build configuration from an external json file. + IConfiguration configuration = new ConfigurationBuilder() + .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) + .Build(); + + // Setup DI container and bind the configuration section "Key" to TestOptions. + IServiceCollection services = new ServiceCollection(); + services.Configure(configuration.GetSection("Key")); + + // Build the service provider. + IServiceProvider serviceProvider = services.BuildServiceProvider(); + + // Get the bound options. + TestOptions options = serviceProvider.GetRequiredService>().Value; + + // Simple output demonstrating binding results. + Console.WriteLine("Configuration bound to TestOptions:"); + Console.WriteLine($"PhoneNumber: {options.PhoneNumber}"); + foreach (var item in options.ExampleDictionary) + { + Console.WriteLine($"{item.Key}: {item.Value}"); + } + } +} +``` + ### Implementing Redactors Redactors can be implemented by inheriting from `Microsoft.Extensions.Compliance.Redaction.Redactor`. For example: diff --git a/test/Libraries/Microsoft.Extensions.Compliance.Abstractions.Tests/Classification/DataClassificationTypeConverterTests.cs b/test/Libraries/Microsoft.Extensions.Compliance.Abstractions.Tests/Classification/DataClassificationTypeConverterTests.cs new file mode 100644 index 00000000000..ff981c34f36 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.Compliance.Abstractions.Tests/Classification/DataClassificationTypeConverterTests.cs @@ -0,0 +1,182 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using FluentAssertions; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Microsoft.Extensions.Compliance.Classification.Tests; + +public class DataClassificationTypeConverterTests +{ + public static IEnumerable DefaultDataClassificationTestData() + { + yield return new object[] { "None", DataClassification.None }; + yield return new object[] { "Unknown", DataClassification.Unknown }; + } + + public static IEnumerable CustomDataClassificationTestData() + { + yield return new object[] { "Example:Test", new DataClassification("Example", "Test") }; + yield return new object[] { "Taxonomy:Value", new DataClassification("Taxonomy", "Value") }; + yield return new object[] { "Custom:Data", new DataClassification("Custom", "Data") }; + } + + [Fact] + public void BindServiceCollection_ShouldReturnIOptionsWithExpectedDataClassification() + { + // Arrange + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new[] + { + new KeyValuePair("Key:Example", "Example:Test"), + new KeyValuePair("Key:Facts:Value", "Taxonomy:Value"), + new KeyValuePair("Key:Facts:Data", "Custom:Data"), + new KeyValuePair("Key:Facts:None", "None"), + new KeyValuePair("Key:Facts:Unknown", "Unknown"), + new KeyValuePair("Key:Facts:Invalid", "Invalid"), + }) + .Build(); + + IServiceCollection serviceCollection = new ServiceCollection() + .Configure(configuration.GetSection("Key")); + + // Act + using var sp = serviceCollection.BuildServiceProvider(); + var options = sp.GetRequiredService>(); + + var expected = new Dictionary + { + { "Value", new DataClassification("Taxonomy", "Value") }, + { "Data", new DataClassification("Custom", "Data") }, + { "None", DataClassification.None }, + { "Unknown", DataClassification.Unknown }, + }; + + // Assert + options.Value.Example.Should().NotBeNull().And.Be(new DataClassification("Example", "Test")); + options.Value.Facts.Should().NotBeEmpty().And.Equal(expected); + + // Odd quirk: binding to dictionary succeeds but doesn't include invalid values + options.Value.Facts.Should().NotContainKey("Invalid"); + } + + [Theory] + [InlineData(typeof(string), true)] + [InlineData(typeof(int), false)] + [InlineData(typeof(DataClassification), false)] + public void CanConvertFrom_ShouldReturnExpectedResult(Type sourceType, bool expected) + { + // Arrange + var converter = new DataClassificationTypeConverter(); + + // Act + var result = converter.CanConvertFrom(null, sourceType); + + // Assert + result.Should().Be(expected); + } + + [Theory] + [InlineData(typeof(DataClassification), true)] + [InlineData(typeof(int), false)] + [InlineData(typeof(string), false)] + public void CanConvertTo_ShouldReturnExpectedResult(Type destinationType, bool expected) + { + // Arrange + var converter = new DataClassificationTypeConverter(); + + // Act + var result = converter.CanConvertTo(null, destinationType); + + // Assert + result.Should().Be(expected); + } + + [Theory] + [InlineData("None", "", "None")] + [InlineData("Unknown", "", "Unknown")] + [InlineData("Example:Test", "Example", "Test")] + public void ConvertFrom_ShouldReturnExpectedResult_ForValidInput(string input, string expectedTaxonomyName, string expectedValue) + { + // Arrange + var converter = new DataClassificationTypeConverter(); + + // Act + var result = converter.ConvertFrom(null, null, input); + + // Assert + result.Should().NotBeNull(); + result.Should().BeOfType(); + +#pragma warning disable CS8605 // Unboxing a possibly null value. + var dataClassification = (DataClassification)result; +#pragma warning restore CS8605 // Unboxing a possibly null value. + + dataClassification.TaxonomyName.Should().Be(expectedTaxonomyName); + dataClassification.Value.Should().Be(expectedValue); + } + + [Theory] + [InlineData("InvalidFormat", typeof(FormatException))] + [InlineData("InvalidFormat:", typeof(FormatException))] + [InlineData(":InvalidFormat", typeof(FormatException))] + [InlineData(":", typeof(FormatException))] + [InlineData("", typeof(FormatException))] + [InlineData("\t", typeof(FormatException))] + [InlineData("\n", typeof(FormatException))] + [InlineData(42, typeof(ArgumentException))] + [InlineData(false, typeof(ArgumentException))] + [InlineData(null, typeof(ArgumentException))] + public void ConvertFrom_ShouldThrowException_ForInvalidInput(object? input, Type expectedException) + { + // Arrange + var converter = new DataClassificationTypeConverter(); + + // Act + var act = () => converter.ConvertFrom(null, null, input!); + + // Assert + Assert.Throws(expectedException, act); + } + + [Theory] + [InlineData("None", true)] + [InlineData("Unknown", true)] + [InlineData("Example:Test", true)] + [InlineData("InvalidFormat", false)] + [InlineData("InvalidFormat:", false)] + [InlineData(":InvalidFormat", false)] + [InlineData(":", false)] + [InlineData("", false)] + [InlineData("\t", false)] + [InlineData("\n", false)] + [InlineData(42, false)] + [InlineData(false, false)] + [InlineData(null, false)] + public void IsValid_ShouldReturnExpectedResult(object? input, bool expected) + { + // Arrange + var converter = new DataClassificationTypeConverter(); + + // Act + var result = converter.IsValid(null, input); + + // Assert + result.Should().Be(expected); + } + + private class TestOptions + { +#pragma warning disable S3459 // Unassigned members should be removed +#pragma warning disable S1144 // Unused private types or members should be removed + public DataClassification? Example { get; set; } +#pragma warning restore S1144 // Unused private types or members should be removed +#pragma warning restore S3459 // Unassigned members should be removed + public IDictionary Facts { get; set; } = new Dictionary(); + } +} diff --git a/test/Libraries/Microsoft.Extensions.Compliance.Abstractions.Tests/Microsoft.Extensions.Compliance.Abstractions.Tests.csproj b/test/Libraries/Microsoft.Extensions.Compliance.Abstractions.Tests/Microsoft.Extensions.Compliance.Abstractions.Tests.csproj index b9d0cf40bb7..19ff10a6142 100644 --- a/test/Libraries/Microsoft.Extensions.Compliance.Abstractions.Tests/Microsoft.Extensions.Compliance.Abstractions.Tests.csproj +++ b/test/Libraries/Microsoft.Extensions.Compliance.Abstractions.Tests/Microsoft.Extensions.Compliance.Abstractions.Tests.csproj @@ -8,4 +8,9 @@ + + + + +