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);
+ }
+}