Skip to content

Commit

Permalink
PII Redaction attributes
Browse files Browse the repository at this point in the history
  • Loading branch information
mhelleborg committed Oct 16, 2024
1 parent 2ca84e2 commit 699a1c9
Show file tree
Hide file tree
Showing 8 changed files with 463 additions and 0 deletions.
11 changes: 11 additions & 0 deletions Source/Analyzers/DescriptorRules.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
2 changes: 2 additions & 0 deletions Source/Analyzers/DiagnosticIds.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ public static class DiagnosticIds

public const string ExceptionInMutation = "SDK0010";

public const string NonNullableRedactableField = "SDK0011";

/// <summary>
/// Aggregate missing the required Attribute.
/// </summary>
Expand Down
82 changes: 82 additions & 0 deletions Source/Analyzers/RedactablePropertyAnalyzer.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Analyzer for <see cref="DescriptorRules.Events.Redaction.RedactablePersonalDataAttribute"/>.
/// </summary>
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class RedactablePropertyAnalyzer : DiagnosticAnalyzer
{
static readonly DiagnosticDescriptor _rule = DescriptorRules.NonNullableRedactableField;

/// <inheritdoc />
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get; } = [_rule];

/// <inheritdoc />
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;
}
}
74 changes: 74 additions & 0 deletions Source/Events/Redaction/PersonalDataRedactedForEvent.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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
/// </summary>
[EventType("de1e7e17-bad5-da7a-fad4-fbc6ec3c0ea5")]
public class PersonalDataRedactedForEvent
{
public string EventId { get; init; }
public string EventAlias { get; init; }
public Dictionary<string, object?> RedactedProperties { get; init; }
public string RedactedBy { get; init; }
public string Reason { get; init; }

/// <summary>
/// Try to create a PersonalDataRedacted event for a given event type
/// Must include reason and redactedBy
/// </summary>
/// <param name="reason"></param>
/// <param name="redactedBy"></param>
/// <param name="redactionEvent"></param>
/// <param name="error"></param>
/// <typeparam name="TEvent"></typeparam>
/// <returns></returns>
public static bool TryCreate<TEvent>(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<TEvent>.EventType;
if (eventType?.Id is null)
{
error = $"EventType not defined for type {typeof(TEvent).Name}";
return false;
}

var redactedFields = RedactedType<TEvent>.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<string, object?>(redactedFields),
RedactedBy = redactedBy,
Reason = reason
};
return true;
}
}
38 changes: 38 additions & 0 deletions Source/Events/Redaction/RedactablePersonalDataAttribute.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
[AttributeUsage(AttributeTargets.Property)]
public class RedactablePersonalDataAttribute : Attribute
{
/// <summary>
/// Gets the value to replace the redacted property with.
/// </summary>
public object? ReplacementValue { get; protected set; } = null;
}

/// <summary>
/// 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.
/// </summary>
[AttributeUsage(AttributeTargets.Property)]
public class RedactablePersonalDataAttribute<T> : RedactablePersonalDataAttribute
{
/// <summary>
/// Initializes a new instance of the <see cref="RedactablePersonalDataAttribute{T}"/> class.
/// </summary>
/// <param name="replacementValue">The value to replace the redacted property with.</param>
public RedactablePersonalDataAttribute(T replacementValue)
{
ReplacementValue = replacementValue;
}
}
55 changes: 55 additions & 0 deletions Source/Events/Redaction/RedactedType.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Tool to get the list of properties that are redacted for a given type
/// </summary>
public static class RedactedType<T> where T : class
{
/// <summary>
/// Get the properties that are redacted for a given type. Could be empty
/// </summary>
// ReSharper disable once StaticMemberInGenericType
public static ImmutableDictionary<string, object?> RedactedProperties { get; } = GetRedactableProperties();

private static ImmutableDictionary<string, object?> GetRedactableProperties()
{
var redactableRemovedProperties =
GetRedactableReplacedProperties()
.ToImmutableDictionary(pair => pair.Key, pair => pair.Value);
return redactableRemovedProperties;
}

// private static IEnumerable<KeyValuePair<string, object?>> GetRemovedProperties()
// {
// return typeof(T).GetProperties()
// .Where(prop => Attribute.IsDefined(prop, typeof(RedactablePersonalDataAttribute)))
// .Select(it => new KeyValuePair<string, object?>(it.Name, it));
// }

private static IEnumerable<KeyValuePair<string, object?>> GetRedactableReplacedProperties()
{
return typeof(T).GetProperties()
.Where(prop => Attribute.IsDefined(prop, typeof(RedactablePersonalDataAttribute)))
.Select(ToReplacement)
.OfType<KeyValuePair<string, object?>>();
}

static KeyValuePair<string, object?>? ToReplacement(PropertyInfo prop)
{
var attribute = prop.GetCustomAttribute<RedactablePersonalDataAttribute>();
if (attribute is null)
{
return null;
}
return new KeyValuePair<string, object?>(prop.Name, attribute.ReplacementValue);
}
}
90 changes: 90 additions & 0 deletions Tests/Analyzers/Diagnostics/RedactablePropertyAnalyzerTests.cs
Original file line number Diff line number Diff line change
@@ -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<RedactablePropertyAnalyzer>
{
[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);
}
}
Loading

0 comments on commit 699a1c9

Please sign in to comment.