diff --git a/Source/Analyzers/DescriptorRules.cs b/Source/Analyzers/DescriptorRules.cs index f9767a73..73ed984d 100644 --- a/Source/Analyzers/DescriptorRules.cs +++ b/Source/Analyzers/DescriptorRules.cs @@ -88,6 +88,17 @@ static class DescriptorRules isEnabledByDefault: true, description: "On-methods can not throw exceptions. This will prevent the system from being able to replay events."); + internal static readonly DiagnosticDescriptor NonNullableRedactableField = + new( + DiagnosticIds.NonNullableRedactableField, + title: "Redactable fields must be nullable", + messageFormat: "Since the value can be deleted, the field '{0}' should be nullable", + DiagnosticCategories.Sdk, + DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "The field should be nullable since the value can be deleted."); + + internal static class Events { internal static readonly DiagnosticDescriptor MissingAttribute = diff --git a/Source/Analyzers/DiagnosticIds.cs b/Source/Analyzers/DiagnosticIds.cs index de92d7e2..79c412a7 100644 --- a/Source/Analyzers/DiagnosticIds.cs +++ b/Source/Analyzers/DiagnosticIds.cs @@ -44,6 +44,8 @@ public static class DiagnosticIds public const string ExceptionInMutation = "SDK0010"; + public const string NonNullableRedactableField = "SDK0011"; + /// /// Aggregate missing the required Attribute. /// diff --git a/Source/Analyzers/RedactablePropertyAnalyzer.cs b/Source/Analyzers/RedactablePropertyAnalyzer.cs new file mode 100644 index 00000000..8000109f --- /dev/null +++ b/Source/Analyzers/RedactablePropertyAnalyzer.cs @@ -0,0 +1,82 @@ +// Copyright (c) Dolittle. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Collections.Immutable; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Dolittle.SDK.Analyzers; + +/// +/// Analyzer for . +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public class RedactablePropertyAnalyzer : DiagnosticAnalyzer +{ + static readonly DiagnosticDescriptor _rule = DescriptorRules.NonNullableRedactableField; + + /// + public override ImmutableArray SupportedDiagnostics { get; } = [_rule]; + + /// + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.Analyze | + GeneratedCodeAnalysisFlags.ReportDiagnostics); + context.EnableConcurrentExecution(); + context.RegisterSyntaxNodeAction(AnalyzeProperty, SyntaxKind.PropertyDeclaration); + context.RegisterSyntaxNodeAction(AnalyzeRecordDeclaration, SyntaxKind.RecordDeclaration); + } + + private static void AnalyzeProperty(SyntaxNodeAnalysisContext context) + { + var propertyDeclaration = (PropertyDeclarationSyntax)context.Node; + var propertySymbol = context.SemanticModel.GetDeclaredSymbol(propertyDeclaration); + + if (propertySymbol == null) return; + + var hasAttribute = propertySymbol.GetAttributes() + .Any(attr => attr.AttributeClass?.Name == "RedactablePersonalDataAttribute"); + + if (!hasAttribute) return; + + if (propertySymbol.Type.NullableAnnotation != NullableAnnotation.Annotated) + { + context.ReportDiagnostic(Diagnostic.Create(_rule, propertyDeclaration.GetLocation(), propertySymbol.Name)); + } + } + + private static void AnalyzeRecordDeclaration(SyntaxNodeAnalysisContext context) + { + var recordDeclaration = (RecordDeclarationSyntax)context.Node; + + // Check if it's a record with a primary constructor + if (recordDeclaration.ParameterList == null) return; + + foreach (var parameter in recordDeclaration.ParameterList.Parameters) + { + var isPersonalDataAnnotated = parameter.AttributeLists + .SelectMany(list => list.Attributes) + .Any(attr => attr.Name.ToString().StartsWith("RedactablePersonalData")); + if (!isPersonalDataAnnotated) + { + continue; + } + + var parameterSymbol = context.SemanticModel.GetDeclaredSymbol(parameter); + if (!IsNullable(parameterSymbol)) + { + context.ReportDiagnostic(Diagnostic.Create(_rule, parameter.GetLocation(), parameterSymbol.Name)); + } + + } + } + + static bool IsNullable(IParameterSymbol? parameterSymbol) + { + return parameterSymbol?.Type.NullableAnnotation == NullableAnnotation.Annotated; + } +} diff --git a/Source/Events/Redaction/PersonalDataRedactedForEvent.cs b/Source/Events/Redaction/PersonalDataRedactedForEvent.cs new file mode 100644 index 00000000..2b6eba07 --- /dev/null +++ b/Source/Events/Redaction/PersonalDataRedactedForEvent.cs @@ -0,0 +1,74 @@ +// Copyright (c) Dolittle. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; + +namespace Dolittle.SDK.Events.Redaction; + +/// +/// Event that triggers redaction of the given personal data +/// It will target the given event type and redact the properties specified within the EventSourceId of the event +/// +[EventType("de1e7e17-bad5-da7a-fad4-fbc6ec3c0ea5")] +public class PersonalDataRedactedForEvent +{ + public string EventId { get; init; } + public string EventAlias { get; init; } + public Dictionary RedactedProperties { get; init; } + public string RedactedBy { get; init; } + public string Reason { get; init; } + + /// + /// Try to create a PersonalDataRedacted event for a given event type + /// Must include reason and redactedBy + /// + /// + /// + /// + /// + /// + /// + public static bool TryCreate(string reason, string redactedBy, + [NotNullWhen(true)] out PersonalDataRedactedForEvent? redactionEvent, [NotNullWhen(false)] out string? error) + where TEvent : class + { + redactionEvent = default; + error = default; + if (string.IsNullOrWhiteSpace(reason)) + { + error = "Reason cannot be empty"; + return false; + } + + if (string.IsNullOrWhiteSpace(redactedBy)) + { + error = "RedactedBy cannot be empty"; + return false; + } + + var eventType = EventTypeMetadata.EventType; + if (eventType?.Id is null) + { + error = $"EventType not defined for type {typeof(TEvent).Name}"; + return false; + } + + var redactedFields = RedactedType.RedactedProperties; + if (redactedFields.Count == 0) + { + error = $"No redacted fields found for event type {eventType.Alias}"; + return false; + } + + redactionEvent = new PersonalDataRedactedForEvent + { + EventId = eventType.Id.ToString(), + EventAlias = eventType.Alias ?? typeof(TEvent).Name, + RedactedProperties = new Dictionary(redactedFields), + RedactedBy = redactedBy, + Reason = reason + }; + return true; + } +} diff --git a/Source/Events/Redaction/RedactablePersonalDataAttribute.cs b/Source/Events/Redaction/RedactablePersonalDataAttribute.cs new file mode 100644 index 00000000..57ce0fa6 --- /dev/null +++ b/Source/Events/Redaction/RedactablePersonalDataAttribute.cs @@ -0,0 +1,38 @@ +// Copyright (c) Dolittle. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; + +namespace Dolittle.SDK.Events.Redaction; + +/// +/// Attribute to mark a property as redactable. +/// This allows the system to remove personal data from persisted events. +/// Properties that should be redactable must be nullable, and redacted properties will be set to null. +/// +[AttributeUsage(AttributeTargets.Property)] +public class RedactablePersonalDataAttribute : Attribute +{ + /// + /// Gets the value to replace the redacted property with. + /// + public object? ReplacementValue { get; protected set; } = null; +} + +/// +/// Attribute to mark a property as redactable. +/// This allows the system to remove personal data from persisted events. +/// This subclass allows for a specific replacement value to be set, instead of the complete removal of the property. +/// +[AttributeUsage(AttributeTargets.Property)] +public class RedactablePersonalDataAttribute : RedactablePersonalDataAttribute +{ + /// + /// Initializes a new instance of the class. + /// + /// The value to replace the redacted property with. + public RedactablePersonalDataAttribute(T replacementValue) + { + ReplacementValue = replacementValue; + } +} diff --git a/Source/Events/Redaction/RedactedType.cs b/Source/Events/Redaction/RedactedType.cs new file mode 100644 index 00000000..bd0ee39e --- /dev/null +++ b/Source/Events/Redaction/RedactedType.cs @@ -0,0 +1,55 @@ +// Copyright (c) Dolittle. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Reflection; + +namespace Dolittle.SDK.Events.Redaction; + +/// +/// Tool to get the list of properties that are redacted for a given type +/// +public static class RedactedType where T : class +{ + /// + /// Get the properties that are redacted for a given type. Could be empty + /// + // ReSharper disable once StaticMemberInGenericType + public static ImmutableDictionary RedactedProperties { get; } = GetRedactableProperties(); + + private static ImmutableDictionary GetRedactableProperties() + { + var redactableRemovedProperties = + GetRedactableReplacedProperties() + .ToImmutableDictionary(pair => pair.Key, pair => pair.Value); + return redactableRemovedProperties; + } + + // private static IEnumerable> GetRemovedProperties() + // { + // return typeof(T).GetProperties() + // .Where(prop => Attribute.IsDefined(prop, typeof(RedactablePersonalDataAttribute))) + // .Select(it => new KeyValuePair(it.Name, it)); + // } + + private static IEnumerable> GetRedactableReplacedProperties() + { + return typeof(T).GetProperties() + .Where(prop => Attribute.IsDefined(prop, typeof(RedactablePersonalDataAttribute))) + .Select(ToReplacement) + .OfType>(); + } + + static KeyValuePair? ToReplacement(PropertyInfo prop) + { + var attribute = prop.GetCustomAttribute(); + if (attribute is null) + { + return null; + } + return new KeyValuePair(prop.Name, attribute.ReplacementValue); + } +} diff --git a/Tests/Analyzers/Diagnostics/RedactablePropertyAnalyzerTests.cs b/Tests/Analyzers/Diagnostics/RedactablePropertyAnalyzerTests.cs new file mode 100644 index 00000000..4a0a9953 --- /dev/null +++ b/Tests/Analyzers/Diagnostics/RedactablePropertyAnalyzerTests.cs @@ -0,0 +1,90 @@ +// Copyright (c) Dolittle. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Threading.Tasks; + +namespace Dolittle.SDK.Analyzers.Diagnostics; + +public class RedactablePropertyAnalyzerTests : AnalyzerTest +{ + [Fact] + public async Task OnPropertyWithoutRedactableAttribute() + { + var code = @" +using Dolittle.SDK.Events; + +[EventType(""44a755a7-e755-4076-bad4-fbc6ec3c0ea5"")] +class SomeEvent +{ + public string SomeProperty { get; set; } +} +"; + + await VerifyAnalyzerFindsNothingAsync(code); + } + + [Fact] + public async Task OnPropertyWithNullableRedactableAttribute() + { + var code = @" +using Dolittle.SDK.Events; +using Dolittle.SDK.Events.Redaction; + +[EventType(""44a755a7-e755-4076-bad4-fbc6ec3c0ea5"")] +class SomeEvent +{ + [RedactablePersonalData] + public string? SomeProperty { get; set; } +} +"; + + await VerifyAnalyzerFindsNothingAsync(code); + } + + [Fact] + public async Task OnPropertyWithNonNullableRedactableAttribute() + { + var code = @" +using Dolittle.SDK.Events; +using Dolittle.SDK.Events.Redaction; + +[EventType(""44a755a7-e755-4076-bad4-fbc6ec3c0ea5"")] +class SomeEvent +{ + [RedactablePersonalData] + public string SomeProperty { get; set; } +} +"; + await VerifyAnalyzerAsync(code, + Diagnostic(DescriptorRules.NonNullableRedactableField) + .WithSpan(8, 5, 9, 45).WithArguments("SomeProperty")); + } + + [Fact] + public async Task OnRecordPropertyWithNonNullableRedactableAttribute() + { + var code = @" +using Dolittle.SDK.Events; +using Dolittle.SDK.Events.Redaction; + +[EventType(""44a755a7-e755-4076-bad4-fbc6ec3c0ea5"")] +record SomeEvent([property: RedactablePersonalData] string SomeProperty); +"; + await VerifyAnalyzerAsync(code, + Diagnostic(DescriptorRules.NonNullableRedactableField) + .WithSpan(6, 18, 6, 72).WithArguments("SomeProperty")); + } + + [Fact] + public async Task OnRecordPropertyWithNullableRedactableAttribute() + { + var code = @" +using Dolittle.SDK.Events; +using Dolittle.SDK.Events.Redaction; + +[EventType(""44a755a7-e755-4076-bad4-fbc6ec3c0ea5"")] +record SomeEvent([property: RedactablePersonalData] string? SomeProperty); +"; + await VerifyAnalyzerFindsNothingAsync(code); + } +} diff --git a/Tests/Events/Redaction/RedactionTests.cs b/Tests/Events/Redaction/RedactionTests.cs new file mode 100644 index 00000000..408c93aa --- /dev/null +++ b/Tests/Events/Redaction/RedactionTests.cs @@ -0,0 +1,111 @@ +using Dolittle.SDK.Events; +using Dolittle.SDK.Events.Redaction; +using FluentAssertions; +using Xunit; + +namespace Events.Tests.Redaction; + +[EventType("fa553ad4-9feb-4ae8-915b-5a7cb27b549e")] +public class NonRedactedEvent +{ + public string SomeName { get; set; } +} + +[EventType("fa553ad4-9feb-4ae8-915b-5a7cb27b549e")] +public record NonRedactedRecord(string SomeName); + +[EventType("f8e38583-d9a4-4783-8ac5-29cc0d006e8b")] +public class RedactedEvent +{ + [RedactablePersonalData(-1)] public int SomeVal { get; set; } + + [RedactablePersonalData("")] + public string? SomeImportantPii { get; set; } + + public string SomethingElse { get; set; } + + [RedactablePersonalData] public DateTimeOffset? BirthDate { get; set; } +} + +[EventType("5577fe91-5955-4b93-98b0-6399647ffdf3")] +public record RedactedRecord( + [property: RedactablePersonalData] string? RedactedParam, + [property: RedactablePersonalData(-999)] int AnotherRedactedParam, + string NonRedactedParam); + +public class RedactionTests +{ + [Fact] + public void WhenTypeHasNoRedactedProperties() + { + RedactedType.RedactedProperties + .Should().BeEmpty(); + } + + [Fact] + public void WhenTypeHasRedactableProperties() + { + RedactedType.RedactedProperties + .Should().BeEquivalentTo(new Dictionary + { + { "SomeVal", -1 }, + { "SomeImportantPii", "" }, + { "BirthDate", null }, + }); + } + + [Fact] + public void WhenRecordHasRedactableProperties() + { + RedactedType.RedactedProperties + .Should().BeEquivalentTo(new Dictionary + { + { "RedactedParam", null }, + { "AnotherRedactedParam", -999 }, + }); + } + + [Fact] + public void WhenRecordHasNoRedactableProperties() + { + RedactedType.RedactedProperties + .Should().BeEmpty(); + } + + [Fact] + public void WhenCreatingRedactionEvent() + { + var reason = "Some reason"; + var redactedBy = "Some person"; + var success = + PersonalDataRedactedForEvent.TryCreate(reason, redactedBy, out var redactionEvent, + out var error); + + success.Should().BeTrue(); + redactionEvent.Should().NotBeNull(); + redactionEvent!.EventId.Should().Be("f8e38583-d9a4-4783-8ac5-29cc0d006e8b"); + redactionEvent.EventAlias.Should().Be(nameof(RedactedEvent)); + redactionEvent.RedactedProperties.Should().BeEquivalentTo(new Dictionary + { + { "SomeVal", -1 }, + { "SomeImportantPii", "" }, + { "BirthDate", null }, + }); + redactionEvent.RedactedBy.Should().Be(redactedBy); + redactionEvent.Reason.Should().Be(reason); + } + + [Theory] + [InlineData("", "Some person", "Reason cannot be empty")] + [InlineData("Some reason", "", "RedactedBy cannot be empty")] + public void WhenProvidingInsufficientInput(string reason, string redactedBy, string expectedError) + { + var success = + PersonalDataRedactedForEvent.TryCreate(reason, redactedBy, out var redactionEvent, + out var error); + + success.Should().BeFalse(); + redactionEvent.Should().BeNull(); + error.Should().Be(expectedError); + } +}