Data erasers - Clear visitor information from the site

Some data protection laws require site owners to comply when visitors request that their personal data be removed from the application. Data erasers are used to delete or anonymize a visitor’s data. They are called from the Right to be forgotten tab in the Data protection app of the Xperience by Kentico administration. Let’s examine the process of writing a data eraser.

Before you start

This guide requires the following:

The examples in this guide require that you:

The example presented in this Data protection guide series is a functional implementation of data protection for contacts, members, and customers in Xperience by Kentico. Note that it does not cover the collection and erasure of Customer data platform profiles and their associated data.

You can copy-paste the code samples into your own solution.

However, if you choose to do so, make sure to consult your legal team to determine whether the implementation, texts, and consent levels meet the requirements of your region and market.

Code samples

You can find a project with completed, working versions of code samples from this guide and others in the finished branch of the Training guides repository.

The main branch of the repository provides a starting point to code along with the guides.

The code samples in this guide are for .NET 8 only.

They come from a project that uses implicit using directives. You may need to add additional using directives to your code if your project does not use this feature.

Extend the eraser configuration UI to include customers

Before we move on to the data eraser, we need to make sure it is configurable for the object types we want to erase.

By default, the configuration dialog on the Right to be forgotten tab includes the following options:

  • Delete contact
  • Delete activities
  • Delete submitted form activities
  • Delete submitted form data
  • Delete member

It makes no mention of commerce data, like customers and orders.

In order to change that, we need to extend the admin UI.

Store admin files separately

We recommend storing any files customizing the Xperience admin UI separately from channel-specific files. In our Training guides repository and this series, we use the TrainingGuides.Admin project for this purpose.

If you are using the main branch of our repository and your solution does not contain this project yet, you can follow the steps in the Set up the project section of the cookie consent guide to define it.

  1. In the TrainingGuides.Admin project, create a new folder called Extenders and add a new model for the dialog to use:

    C#
    TrainingGuidesDataErasureDialogModel.cs
    
     using Kentico.Xperience.Admin.Base.Forms;
     using Kentico.Xperience.Admin.Base.FormAnnotations;
     using Kentico.Xperience.Admin.DigitalMarketing;
    
     namespace TrainingGuides.Admin.Extenders;
    
     public class TrainingGuidesDataErasureDialogModel : IDataErasureDialogModel
     {
         /// <summary>
         /// Indicates whether corresponding contacts should be deleted.
         /// </summary>
         [CheckBoxComponent(Label = "Delete contact", Order = 0)]
         public bool DeleteContacts { get; set; } = false;
    
    
         /// <summary>
         /// Indicates whether all activities of corresponding contacts should be deleted.
         /// </summary>
         [CheckBoxComponent(Label = "Delete activities", Order = 1)]
         public bool DeleteActivities { get; set; } = false;
    
    
         /// <summary>
         /// Indicates whether form submission activities of corresponding contacts should be deleted.
         /// </summary>
         [CheckBoxComponent(Label = "Delete submitted form activities", Order = 2)]
         public bool DeleteSubmittedFormsActivities { get; set; } = false;
    
    
         /// <summary>
         /// Indicates whether submitted forms of corresponding contacts should be deleted.
         /// </summary>
         [CheckBoxComponent(Label = "Delete submitted form data", Order = 3)]
         public bool DeleteSubmittedFormsData { get; set; } = false;
    
    
         /// <summary>
         /// Indicates whether corresponding members should be deleted.
         /// </summary>
         [CheckBoxComponent(Label = "Delete member", Order = 4)]
         public bool DeleteMembers { get; set; } = false;
    
         /// <summary>
         /// Indicates whether corresponding customers, addresses, and orders should be deleted.
         /// </summary>
         [CheckBoxComponent(Label = "Delete customer and order data", Order = 5)]
         public bool DeleteCustomerAndOrderData { get; set; } = false;
    
    
         /// <inheritdoc/>
         public virtual Task<ValidationResult> Validate()
         {
             if (IsAnyOptionSelected())
             {
                 return ValidationResult.SuccessResult();
             }
    
             return ValidationResult.FailResult("No data selected for erasure.");
         }
    
    
         /// <summary>
         /// Indicates that at least one of erasure options was selected.
         /// </summary>
         protected virtual bool IsAnyOptionSelected() => DeleteContacts || DeleteActivities || DeleteSubmittedFormsActivities
                 || DeleteSubmittedFormsData || DeleteMembers || DeleteCustomerAndOrderData;
     }
     
  2. Then create a PageExtender that sets the data erasure model of the RightToBeForgotten page to TrainingGuidesDataErasureDialogModel.

    C#
    TrainingGuidesRightToBeForgottenExtender.cs
    
     using Kentico.Xperience.Admin.Base;
     using Kentico.Xperience.Admin.DigitalMarketing.UIPages;
     using TrainingGuides.Admin.Extenders;
    
     [assembly: PageExtender(typeof(TrainingGuidesRightToBeForgottenExtender))]
    
     namespace TrainingGuides.Admin.Extenders;
    
     public class TrainingGuidesRightToBeForgottenExtender : PageExtender<RightToBeForgotten>
     {
         public override Task ConfigurePage()
         {
             // Assigns a custom erasure dialog model to the page configuration
             Page.PageConfiguration.DataErasureDialogModel = new TrainingGuidesDataErasureDialogModel();
    
             return base.ConfigurePage();
         }
     }
     

Add a data eraser

This example’s data eraser is similar in some ways to the data collector from earlier, but it differs in a few key ways. Firstly, you don’t need to choose between different types of data writers in a data eraser because you are deleting it rather than compiling it for display or transfer.

This data eraser will follow the same principle as the documentation example, but it will include additional options for deleting form data, form submission activities, members, customers, and orders.

Unlike the data collector, this class does not need to compile CollectedColumn objects. It will simply delete objects with personal data. Additionally, we only need to delete some of the objects that the data collector displayed. For example, we should not delete a whole contact group when erasing the contact who was once a member.

As a result, the data eraser will be much leaner than the collector, and will more easily fit in a single file.

Alternatively, you can make a data eraser that only anonymizes objects (overwrites columns with personal identifying information with anonymous values) instead of deleting them. If you decide to anonymize certain object types, you can take a similar approach and specify which columns you want to anonymize.

  1. Create a new Erasers folder in TrainingGuides.Web/Features/DataProtection and add a new file called DataEraser.cs.
  2. Create readable constants to hold the names of configuration values from the data erasure dialog.
  3. Use the FormCollectionService from earlier to retrieve forms and form submissions.
  4. Include methods to delete the following:
    • Form submission activities

    • All activities

    • Contacts

    • Submitted form data

    • Members

    • Customers

    • Orders

    • Third-party commerce addresses

      If a different customer entered the data subject’s address, for example, to ship an order to them, that address may still be subject to data protection laws. We recommend consulting your legal team for specific guidance.
C#
DataEraser.cs

using CMS.Activities;
using CMS.Base;
using CMS.Commerce;
using CMS.ContactManagement;
using CMS.DataEngine;
using CMS.DataProtection;
using CMS.Helpers;
using CMS.Membership;
using CMS.OnlineForms;
using TrainingGuides.Web.Features.DataProtection.Collectors;
using TrainingGuides.Web.Features.DataProtection.Services;
using TrainingGuides.Admin.Extenders;

namespace TrainingGuides.Web.Features.DataProtection.Erasers;

public class DataEraser : IPersonalDataEraser
{
    private const string DELETE_FORM_ACTIVITIES = nameof(TrainingGuidesDataErasureDialogModel.DeleteSubmittedFormsActivities);
    private const string DELETE_FORM_DATA = nameof(TrainingGuidesDataErasureDialogModel.DeleteSubmittedFormsData);
    private const string DELETE_ACTIVITIES = nameof(TrainingGuidesDataErasureDialogModel.DeleteActivities);
    private const string DELETE_CONTACTS = nameof(TrainingGuidesDataErasureDialogModel.DeleteContacts);
    private const string DELETE_MEMBERS = nameof(TrainingGuidesDataErasureDialogModel.DeleteMembers);
    private const string DELETE_CUSTOMER_DATA = nameof(TrainingGuidesDataErasureDialogModel.DeleteCustomerAndOrderData);
    private readonly IFormCollectionService formCollectionService;
    private readonly Dictionary<Guid, FormDefinition> forms;

    private readonly IInfoProvider<ContactInfo> contactInfoProvider;
    private readonly IInfoProvider<ActivityInfo> activityInfoProvider;
    private readonly IInfoProvider<ConsentAgreementInfo> consentAgreementInfoProvider;
    private readonly IInfoProvider<BizFormInfo> bizFormInfoProvider;
    private readonly IInfoProvider<MemberInfo> memberInfoProvider;
    private readonly IInfoProvider<CustomerInfo> customerInfoProvider;
    private readonly IInfoProvider<OrderInfo> orderInfoProvider;
    private readonly IInfoProvider<OrderAddressInfo> orderAddressInfoProvider;
    private readonly IInfoProvider<CustomerAddressInfo> customerAddressInfoProvider;

    public DataEraser(IFormCollectionService formCollectionService,
        IInfoProvider<ContactInfo> contactInfoProvider,
        IInfoProvider<ActivityInfo> activityInfoProvider,
        IInfoProvider<ConsentAgreementInfo> consentAgreementInfoProvider,
        IInfoProvider<BizFormInfo> bizFormInfoProvider,
        IInfoProvider<MemberInfo> memberInfoProvider,
        IInfoProvider<CustomerInfo> customerInfoProvider,
        IInfoProvider<OrderInfo> orderInfoProvider,
        IInfoProvider<OrderAddressInfo> orderAddressInfoProvider,
        IInfoProvider<CustomerAddressInfo> customerAddressInfoProvider)
    {
        this.formCollectionService = formCollectionService;

        this.contactInfoProvider = contactInfoProvider;
        this.activityInfoProvider = activityInfoProvider;
        this.consentAgreementInfoProvider = consentAgreementInfoProvider;
        this.bizFormInfoProvider = bizFormInfoProvider;
        this.memberInfoProvider = memberInfoProvider;
        this.customerInfoProvider = customerInfoProvider;
        this.orderInfoProvider = orderInfoProvider;
        this.orderAddressInfoProvider = orderAddressInfoProvider;
        this.customerAddressInfoProvider = customerAddressInfoProvider;
        forms = this.formCollectionService.GetForms();
    }

    public void Erase(IEnumerable<BaseInfo> identities, IDictionary<string, object> configuration)
    {
        var contacts = identities.OfType<ContactInfo>().ToList();
        var members = identities.OfType<MemberInfo>().ToList();
        var customers = identities.OfType<CustomerInfo>().ToList();

        if (!(contacts.Any() || members.Any() || customers.Any()))
        {
            return;
        }

        using (new CMSActionContext())
        {
            if (contacts.Any())
            {
                var contactIds = contacts.Select(c => c.ContactID).ToList();
                var contactEmails = contacts.Select(c => c.ContactEmail).ToList();

                DeleteSubmittedFormsActivities(contactIds, configuration);
                DeleteActivities(contactIds, configuration);
                DeleteContacts(contacts, configuration);
                DeleteSiteSubmittedFormsData(contactEmails, contactIds, configuration);
            }
            if (members.Any())
            {
                DeleteMembers(members, configuration);
            }
            if (customers.Any())
            {
#warning "Check the laws of your jurisdiction before deleting or anonymizing customer orders, as your organization may be legally required to keep them for a certain period of time."
                DeleteThirdPartyAddresses(customers.Select(c => c.CustomerEmail), configuration);
                DeleteCustomerOrders(customers.Select(c => c.CustomerID), configuration);
                DeleteCustomers(customers, configuration);
            }
        }
    }

    private void DeleteSubmittedFormsActivities(ICollection<int> contactIds,
        IDictionary<string, object> configuration)
    {
        if (configuration.TryGetValue(DELETE_FORM_ACTIVITIES, out object? deleteSubmittedFormsActivities)
            && ValidationHelper.GetBoolean(deleteSubmittedFormsActivities, false))
        {
            activityInfoProvider.BulkDelete(new WhereCondition()
                .WhereEquals("ActivityType", PredefinedActivityType.BIZFORM_SUBMIT)
                .WhereIn("ActivityContactID", contactIds));
        }
    }

    private void DeleteSiteSubmittedFormsData(ICollection<string> emails, ICollection<int> contactIDs,
        IDictionary<string, object> configuration)
    {
        if (configuration.TryGetValue(DELETE_FORM_DATA, out object? deleteSubmittedForms)
            && ValidationHelper.GetBoolean(deleteSubmittedForms, false))
        {
            var consentAgreementGuids = consentAgreementInfoProvider.Get()
                .Columns("ConsentAgreementGuid")
                .WhereIn("ConsentAgreementContactID", contactIDs);

            var formClasses = bizFormInfoProvider.Get()
                .Source(s => s.LeftJoin<DataClassInfo>("CMS_Form.FormClassID", "ClassID"))
                .WhereIn("FormGUID", forms.Select(pair => pair.Key).ToList());

            formClasses.ForEachRow(row =>
            {
                var bizForm = new BizFormInfo(row);
                var formDefinition = forms[bizForm.FormGUID];

                var bizFormItems = formCollectionService.GetBizFormItems(emails, consentAgreementGuids, row, formDefinition);

                foreach (var bizFormItem in bizFormItems)
                {
                    bizFormItem.Delete();
                }
            });
        }
    }

    private void DeleteActivities(List<int> contactIds, IDictionary<string, object> configuration)
    {
        if (configuration.TryGetValue(DELETE_ACTIVITIES, out object? deleteActivities)
            && ValidationHelper.GetBoolean(deleteActivities, false))
        {
            activityInfoProvider.BulkDelete(
                new WhereCondition().WhereIn("ActivityContactID", contactIds));
        }
    }

    private void DeleteContacts(IEnumerable<ContactInfo> contacts, IDictionary<string, object> configuration)
    {
        if (configuration.TryGetValue(DELETE_CONTACTS, out object? deleteContacts) &&
            ValidationHelper.GetBoolean(deleteContacts, false))
        {
            foreach (var contactInfo in contacts.Where(contact => contact.ContactID > 0))
            {
                contactInfoProvider.Delete(contactInfo);
            }
        }
    }

    private void DeleteMembers(IEnumerable<MemberInfo> members, IDictionary<string, object> configuration)
    {
        if (configuration.TryGetValue(DELETE_MEMBERS, out object? deleteMembers)
            && ValidationHelper.GetBoolean(deleteMembers, false))
        {
            foreach (var memberInfo in members.Where(member => member.MemberID > 0))
            {
                memberInfoProvider.Delete(memberInfo);
            }
        }
    }

    private void DeleteCustomers(IEnumerable<CustomerInfo> customers, IDictionary<string, object> configuration)
    {
        if (configuration.TryGetValue(DELETE_CUSTOMER_DATA, out object? deleteCustomers)
            && ValidationHelper.GetBoolean(deleteCustomers, false))
        {
            foreach (var customerInfo in customers.Where(customer => customer.CustomerID > 0))
            {
                // This automatically deletes related customer addresses
                customerInfoProvider.Delete(customerInfo);
            }
        }
    }

    private void DeleteCustomerOrders(IEnumerable<int> customerIds, IDictionary<string, object> configuration)
    {
        if (configuration.TryGetValue(DELETE_CUSTOMER_DATA, out object? deleteCustomersAndOrders)
            && ValidationHelper.GetBoolean(deleteCustomersAndOrders, false))
        {
            var customerOrders = orderInfoProvider.Get()
                .WhereIn(nameof(OrderInfo.OrderCustomerID), customerIds)
                .ToList();

            foreach (var order in customerOrders)
            {
                // This automatically deletes related order items and order addresses.
                orderInfoProvider.Delete(order);
            }
        }
    }

    private void DeleteThirdPartyAddresses(IEnumerable<string> customerEmails, IDictionary<string, object> configuration)
    {
        if (configuration.TryGetValue(DELETE_CUSTOMER_DATA, out object? deleteCustomersAndOrders)
            && ValidationHelper.GetBoolean(deleteCustomersAndOrders, false))
        {
            var orderAddresses = orderAddressInfoProvider.Get()
                .WhereIn(nameof(OrderAddressInfo.OrderAddressEmail), customerEmails)
                .ToList();

            var customerAddresses = customerAddressInfoProvider.Get()
                .WhereIn(nameof(CustomerAddressInfo.CustomerAddressEmail), customerEmails)
                .ToList();

            foreach (var orderAddress in orderAddresses)
            {
                // This automatically deletes related orders and order items.
                orderAddressInfoProvider.Delete(orderAddress);
            }

            foreach (var customerAddress in customerAddresses)
            {
                customerAddressInfoProvider.Delete(customerAddress);
            }
        }
    }
}

Keep in mind that your eraser needs to clear or anonymize all object types that store or reference visitor data in any way to comply with data protection laws. This example does not cover any custom logic specific to your solution.

Register the eraser

To complete this example, register the data eraser so that it is invoked by the system on the Right to be forgotten tab of the Data protection application in Xperience.

Open the DataProtectionRegistrationModule.cs file, and add the highlighted code to the OnInit method.

C#
DataProtectionRegistrationModule.cs

using CMS;
using CMS.Core;
using CMS.DataEngine;
using CMS.DataProtection;
using TrainingGuides.Web.Features.DataProtection.Collectors;
using TrainingGuides.Web.Features.DataProtection.Erasers;
using TrainingGuides.Web.Features.DataProtection.Shared;

[assembly: RegisterModule(
    type: typeof(DataProtectionRegistrationModule))]

namespace TrainingGuides.Web.Features.DataProtection.Shared;

public class DataProtectionRegistrationModule : Module
{
    public DataProtectionRegistrationModule()
        : base("DataProtectionRegistration")
    {
    }

    // Contains initialization code that is executed when the application starts
    protected override void OnInit(ModuleInitParameters parameters)
    {
        var serviceProvider = parameters.Services.GetRequiredService<IServiceProvider>();

        base.OnInit(parameters);

        // Adds the IdentityCollector to the collection of registered identity collectors
        IdentityCollectorRegister.Instance.Add(ActivatorUtilities.CreateInstance<IdentityCollector>(serviceProvider));

        // Adds the DataCollector to the collection of registered personal data collectors
        PersonalDataCollectorRegister.Instance.Add(ActivatorUtilities.CreateInstance<DataCollector>(serviceProvider));

        // Adds the DataEraser to the collection of registered personal data erasers
        PersonalDataEraserRegister.Instance.Add(ActivatorUtilities.CreateInstance<DataEraser>(serviceProvider));
    }
}

This code will fit anywhere within the scope of the method, though it may be best to put it after the existing code from earlier in this series in order to mirror the ordering of the data protection UI and the order in which the data eraser is called in relation to the identity collector.

Now, you can open the Data protection application from the Configuration category of the administration interface. On the Right to be forgotten tab, you can enter the email of a known contact, member, or customer and choose which data to delete. Xperience will use the DataEraser to remove their information from the system, as shown in the video below: