Skip to main content

GDPR Erasure

GDPR Article 17 ("Right to be Forgotten") requires organizations to delete personal data upon request. Dispatch implements this through cryptographic erasure (crypto-shredding), which renders data irrecoverable by deleting encryption keys.

Before You Start

  • .NET 8.0+ (or .NET 9/10 for latest features)
  • Install the required packages:
    dotnet add package Excalibur.Dispatch.Security
  • Familiarity with encryption architecture and data masking

Overview

sequenceDiagram
participant DS as Data Subject
participant API as Erasure API
participant ES as ErasureService
participant LH as LegalHoldService
participant KMS as Key Management

DS->>API: Request Erasure
API->>ES: RequestErasureAsync()
ES->>LH: Check Legal Holds
LH-->>ES: No Holds
ES-->>API: Scheduled (Grace Period)

Note over ES: 72 hours grace period

ES->>KMS: Delete Encryption Keys
KMS-->>ES: Keys Deleted
ES-->>API: Certificate Generated

Quick Start

Configuration

services.AddGdprErasure(options =>
{
options.DefaultGracePeriod = TimeSpan.FromHours(72);
options.RequireVerification = true;
});

// Development (in-memory stores)
services.AddInMemoryErasureStore();
services.AddInMemoryLegalHoldStore();
services.AddLegalHoldService();
services.AddErasureScheduler();

// Production (SQL Server storage)
// Package: Excalibur.Compliance.SqlServer
services.AddSqlServerErasureStore(options =>
{
options.ConnectionString = connectionString;
options.SchemaName = "compliance";
});

Submit Erasure Request

public class ErasureController : ControllerBase
{
private readonly IErasureService _erasureService;

[HttpPost("erasure")]
public async Task<IActionResult> RequestErasure(
[FromBody] ErasureRequestDto dto,
CancellationToken ct)
{
var request = new ErasureRequest
{
DataSubjectId = dto.SubjectId,
IdType = DataSubjectIdType.UserId,
LegalBasis = ErasureLegalBasis.DataSubjectRequest,
RequestedBy = User.Identity?.Name ?? "anonymous",
TenantId = dto.TenantId,
Scope = ErasureScope.User
};

var result = await _erasureService.RequestErasureAsync(request, ct);

return Ok(new
{
RequestId = result.RequestId,
Status = result.Status,
ScheduledFor = result.ScheduledExecutionTime
});
}
}

Erasure Workflow

1. Request Submission

var request = new ErasureRequest
{
DataSubjectId = "user-12345",
IdType = DataSubjectIdType.UserId,
LegalBasis = ErasureLegalBasis.DataSubjectRequest,
RequestedBy = "[email protected]",
TenantId = "tenant-abc",
Scope = ErasureScope.User
};

var result = await _erasureService.RequestErasureAsync(request, ct);

2. Grace Period

Requests enter a configurable grace period (default 72 hours) before execution:

services.AddGdprErasure(options =>
{
// Default grace period (minimum recommended 72 hours for production)
options.DefaultGracePeriod = TimeSpan.FromHours(72);

// Configure min/max bounds
options.MinimumGracePeriod = TimeSpan.FromHours(24);
options.MaximumGracePeriod = TimeSpan.FromDays(30);
});

3. Cancellation (During Grace Period)

var cancelled = await _erasureService.CancelErasureAsync(
requestId: result.RequestId,
reason: "Request withdrawn by data subject",
cancelledBy: "[email protected]",
ct);

if (!cancelled)
{
// Request already executed or not found
}

4. Execution (Crypto-Shredding)

After the grace period, the scheduler executes erasure:

// Typically called by background service
var execution = await _erasureService.ExecuteAsync(requestId, ct);

if (execution.Success)
{
_logger.LogInformation(
"Erasure complete: {KeysDeleted} keys deleted, {RecordsAffected} records",
execution.KeysDeleted,
execution.RecordsAffected);
}
Partial Failure Handling

If any contributor erasure fails, the request is marked PartiallyCompleted (not Completed). A compliance certificate is not generated for partially completed erasures. Monitor the ErasurePartiallyCompleted event (ID 92729) and retry or investigate failed contributors:

var execution = await _erasureService.ExecuteAsync(requestId, ct);

switch (execution.Status)
{
case ErasureStatus.Completed:
// All contributors succeeded -- certificate available
break;
case ErasureStatus.PartiallyCompleted:
// Some contributors failed -- investigate and retry
_logger.LogWarning("Partial erasure: {Errors}", execution.Errors);
break;
case ErasureStatus.Failed:
// All contributors failed
_logger.LogError("Erasure failed completely for {RequestId}", requestId);
break;
}

5. Compliance Certificate

Generate cryptographic proof of erasure:

var certificate = await _erasureService.GenerateCertificateAsync(requestId, ct);

// Certificate contains:
// - Request details
// - Execution timestamp
// - Keys deleted
// - SHA-256 signature

Article 17(3) exceptions prevent erasure for:

  • Legal claims
  • Litigation holds
  • Regulatory investigations
  • Legal obligations

Check for Holds

public class LegalHoldAwareErasure
{
private readonly ILegalHoldService _holdService;
private readonly IErasureService _erasureService;

public async Task<ErasureResult> SafeErasure(
ErasureRequest request,
CancellationToken ct)
{
// Check for active holds (requires DataSubjectIdType)
var checkResult = await _holdService.CheckHoldsAsync(
request.DataSubjectId,
request.IdType,
request.TenantId,
ct);

if (checkResult.HasActiveHolds)
{
throw new ErasureException(
$"Cannot erase: {checkResult.ActiveHolds.Count} active legal hold(s)");
}

return await _erasureService.RequestErasureAsync(request, ct);
}
}
var hold = await _holdService.CreateHoldAsync(new LegalHoldRequest
{
DataSubjectId = "user-12345",
IdType = DataSubjectIdType.UserId,
TenantId = "tenant-abc",
Basis = LegalHoldBasis.LitigationHold,
CaseReference = "Case #2024-001",
Description = "Pending lawsuit - Case #2024-001",
CreatedBy = "[email protected]",
ExpiresAt = DateTimeOffset.UtcNow.AddYears(2)
}, ct);

Release Hold

await _holdService.ReleaseHoldAsync(
holdId: hold.HoldId,
reason: "Litigation concluded",
releasedBy: "[email protected]",
ct);

Erasure Scopes

Control what data is erased:

public enum ErasureScope
{
User = 0, // Erase all data for a specific user
Tenant = 1, // Erase all data for an entire tenant
Selective = 2 // Erase specific data categories only
}

// Selective erasure with data categories
var request = new ErasureRequest
{
DataSubjectId = "user-12345",
IdType = DataSubjectIdType.UserId,
LegalBasis = ErasureLegalBasis.ConsentWithdrawal,
RequestedBy = "[email protected]",
Scope = ErasureScope.Selective,
DataCategories = ["marketing", "analytics"]
};

Data Inventory

Track where personal data is stored. Register the data inventory service via DI:

// Register data inventory services
services.AddDataInventoryService();
services.AddInMemoryDataInventoryStore(); // or SQL Server store for production
note

The IDataInventoryService provides registration and discovery of personal data locations across your system, enabling comprehensive erasure and Records of Processing Activities (RoPA) documentation.

Verification

Check Erasure Status

var status = await _erasureService.GetStatusAsync(requestId, ct);

switch (status?.Status)
{
case ErasureRequestStatus.Scheduled:
// In grace period
break;
case ErasureRequestStatus.Completed:
// Successfully erased
break;
case ErasureRequestStatus.Failed:
// Execution failed
break;
case ErasureRequestStatus.Cancelled:
// Cancelled during grace period
break;
}

List Requests

// Inject IErasureQueryStore (ISP sub-interface of IErasureStore)
var requests = await _erasureQueryStore.ListRequestsAsync(
status: ErasureRequestStatus.Completed,
tenantId: "tenant-abc",
fromDate: DateTimeOffset.UtcNow.AddDays(-30),
toDate: DateTimeOffset.UtcNow,
ct);

Background Scheduler

Register the erasure scheduler to automatically execute requests after the grace period:

// Register the scheduler service
services.AddErasureScheduler();

For serverless environments, trigger erasure execution manually via IErasureService.ExecuteAsync:

public class ErasureFunction
{
private readonly IErasureService _erasureService;
private readonly IErasureQueryStore _erasureQueryStore;

[Function("ProcessErasureRequests")]
public async Task Run(
[TimerTrigger("0 */5 * * * *")] TimerInfo timer,
CancellationToken ct)
{
// List scheduled requests ready for execution (IErasureQueryStore)
var scheduled = await _erasureQueryStore.ListRequestsAsync(
status: ErasureRequestStatus.Scheduled,
tenantId: null,
fromDate: null,
toDate: DateTimeOffset.UtcNow,
ct);

foreach (var request in scheduled)
{
await _erasureService.ExecuteAsync(request.RequestId, ct);
}
}
}

Database Schema

SQL Server

CREATE SCHEMA [compliance];

CREATE TABLE [compliance].[ErasureRequests] (
[Id] UNIQUEIDENTIFIER NOT NULL,
[DataSubjectId] NVARCHAR(256) NOT NULL,
[TenantId] NVARCHAR(100) NOT NULL,
[Status] INT NOT NULL,
[Scope] INT NOT NULL,
[RequestedBy] NVARCHAR(256) NOT NULL,
[RequestedAt] DATETIME2 NOT NULL,
[ScheduledFor] DATETIME2 NOT NULL,
[ExecutedAt] DATETIME2 NULL,
[CancelledAt] DATETIME2 NULL,
[Reason] NVARCHAR(MAX) NULL,

CONSTRAINT [PK_ErasureRequests] PRIMARY KEY ([Id])
);

CREATE INDEX [IX_ErasureRequests_Status]
ON [compliance].[ErasureRequests] ([Status], [ScheduledFor])
WHERE [Status] = 1; -- Scheduled

Testing

Unit Tests

[Fact]
public async Task Should_Schedule_Erasure_With_Grace_Period()
{
// Arrange
var request = new ErasureRequest
{
DataSubjectId = "user-123",
IdType = DataSubjectIdType.UserId,
LegalBasis = ErasureLegalBasis.DataSubjectRequest,
RequestedBy = "[email protected]",
TenantId = "tenant-abc"
};

// Act
var result = await _erasureService.RequestErasureAsync(request, CancellationToken.None);

// Assert
result.Status.ShouldBe(ErasureRequestStatus.Scheduled);
result.ScheduledExecutionTime.ShouldBeGreaterThan(DateTimeOffset.UtcNow);
}

[Fact]
public async Task Should_Block_Erasure_With_Legal_Hold()
{
// Arrange — create a legal hold first
await _holdService.CreateHoldAsync(new LegalHoldRequest
{
DataSubjectId = "user-123",
IdType = DataSubjectIdType.UserId,
Basis = LegalHoldBasis.LitigationHold,
CaseReference = "CASE-001",
Description = "Test litigation hold",
CreatedBy = "[email protected]"
}, CancellationToken.None);

var request = new ErasureRequest
{
DataSubjectId = "user-123",
IdType = DataSubjectIdType.UserId,
LegalBasis = ErasureLegalBasis.DataSubjectRequest,
RequestedBy = "[email protected]"
};

// Act & Assert — erasure should be blocked
var result = await _erasureService.RequestErasureAsync(request, CancellationToken.None);
result.Status.ShouldBe(ErasureRequestStatus.BlockedByLegalHold);
}

Event Store Erasure

When using event sourcing, GDPR erasure must extend to event stores. The IEventStoreErasure interface (in Excalibur.EventSourcing.Abstractions) enables cryptographic erasure at the event store level.

IEventStoreErasure Interface

namespace Excalibur.EventSourcing.Abstractions;

public interface IEventStoreErasure
{
/// <summary>
/// Erases all event payloads for the specified aggregate, replacing them
/// with a tombstone marker. The stream is retained for referential integrity.
/// </summary>
Task<int> EraseEventsAsync(
string aggregateId,
string aggregateType,
Guid erasureRequestId,
CancellationToken cancellationToken);

/// <summary>
/// Checks whether erasure has been performed for the specified aggregate.
/// </summary>
Task<bool> IsErasedAsync(
string aggregateId,
string aggregateType,
CancellationToken cancellationToken);
}

Event store providers that support GDPR erasure implement this interface. Use GetService(typeof(IEventStoreErasure)) to probe for erasure capability at runtime:

if (eventStore is IEventStoreErasure erasure)
{
var count = await erasure.EraseEventsAsync(
aggregateId: "user-12345",
aggregateType: "UserProfile",
erasureRequestId: requestId,
cancellationToken);

logger.LogInformation("Erased {Count} events for aggregate {AggregateId}", count, "user-12345");
}

DataSubjectHasher

All GDPR components use DataSubjectHasher for consistent SHA-256 hashing of data subject identifiers:

using Excalibur.Dispatch.Compliance;

// Hash a data subject ID for lookup/storage
var hashedId = DataSubjectHasher.HashDataSubjectId("user-12345");
// Returns uppercase hex-encoded SHA-256 hash

This ensures that plain-text data subject IDs are never stored in erasure request tables or audit logs.

Implementing Custom Event Store Erasure

If you have a custom event store, implement IEventStoreErasure alongside your IEventStore:

public class MyEventStore : IEventStore, IEventStoreErasure
{
public async Task<int> EraseEventsAsync(
string aggregateId,
string aggregateType,
Guid erasureRequestId,
CancellationToken cancellationToken)
{
// Replace event payloads with tombstone markers
// Retain the stream and event metadata for referential integrity
var count = await ReplacePayloadsWithTombstone(aggregateId, aggregateType, cancellationToken);

// Log the erasure for audit
await RecordErasureAudit(aggregateId, erasureRequestId, count, cancellationToken);

return count;
}

public async Task<bool> IsErasedAsync(
string aggregateId,
string aggregateType,
CancellationToken cancellationToken)
{
return await CheckForTombstoneMarker(aggregateId, aggregateType, cancellationToken);
}
}
Key Design Decision

Event store erasure uses tombstoning (replacing payloads) rather than deletion (removing events). This preserves the event sequence and version numbers for other aggregates that may reference these events, while making the personal data irrecoverable.

Best Practices

PracticeRecommendation
Grace period72 hours minimum for production
Legal holdsAlways check before execution
Audit loggingEnable for compliance evidence
Key rotationUse separate keys per data subject
VerificationGenerate certificates for all completions
Data inventoryMaintain accurate data location registry

Compliance Mapping

GDPR ArticleFeature
Article 17(1)ErasureService.RequestErasureAsync()
Article 17(2)Cascade to all data locations via DataInventory
Article 17(3)(b)LegalHoldService for compliance obligations
Article 17(3)(e)LegalHoldService for legal claims

Next Steps

See Also